Server Layer
The classes/ directory. Services, errors, loggers
classes/ is split into three folders, all of them server-only:
When import 'server-only' is used at the top of a file, this causes a build
error if anything in the client bundle tries to import it. This is intentional
to stop anything sensitive on the server side leaking to the browser
| Folder | What lives here |
|---|---|
classes/services/ | Business logic + integrations |
classes/errors/ | Structured error types |
classes/loggers/ | Logger classes |
Design Patterns
If you are not familiar with Design Patterns, check out these two superb resources: Refactoring Guru & 7 Design Patterns EVERY Developer Should Know
Design patterns in software engineering are essentially blueprints you can follow to make your code more maintainable, extensible, and predictable.
As abstractions in engineering keep rising (particularly with the advent of AI-generated code) the fundamentals become more important, not less. Good patterns give you a consistent mental model of the codebase regardless of how it was written. In an agentic workflow this is super important! Define the patterns once and then let the agent repeat them.
In the server layer design patterns are applied deliberately, creating repeatable shapes for structuring code.
The patterns used / referenced are:
- Singleton (module-scoped instance) - The intent is a singleton but Next.js compiles API routes and server actions into separate bundles, so in practice you get one instance per bundle rather than one truly global instance
- Factory - Per-request instantiation when the service needs context baked in
- Inheritance vs Composition - Base clients handle cross-cutting concerns; domain clients extend or compose depending on what they need
Why classes?
You don't see many classes in React these days. Hooks replaced class components, and the general vibe became "functions good, classes bad." For the UI, that vibe is generally right. Functions and hooks are just a better fit for components.
However, Next.js changed things somewhat. As a "full-stack" framework developers are often writing both frontend and backend code in one project and the "just use functions" philosophy doesn't really cut it here.
Using a class for each domain service provides a number of benefits:
-
Instantiation control - A module export is always a singleton by nature, you can't control it. A class lets you make that choice explicitly, which matters when some services need per-request instances (see Module-scoped instances vs factory below).
-
Code organisation - A class groups all related methods for a domain in one tidy place.
index.ts// Everything related to example items - in one place, nothing else class ExampleItemService { async getAll(): Promise<ExampleItem[]> { ... } async getById(id: number): Promise<ExampleItem | null> { ... } async create(input: ExampleItemInput): Promise<ExampleItem> { ... } async update(id: number, input: ExampleItemUpdateInput): Promise<ExampleItem | null> { ... } async delete(id: number): Promise<boolean> { ... } } -
Import ergonomics - With plain function exports you either import every method individually, which is noisy or use
import *to avoid the noise, which then breaks tree-shaking and preventsimport typefrom working cleanly:import { getItems, createItem, updateItem, deleteItem, } from '@/services/example-item';import * as exampleItemService from '@/services/example-item';Whereas with a class instance you have one clean import, everything on it:
import { exampleItemService } from '@/classes/services/example-item'; const items = await exampleItemService.getAll(); const item = await exampleItemService.create(input);
Service shape
Every service class follows the same shape:
- Lives in
classes/services/<domain>/ - imports
server-only - Takes config through the constructor, and exposes only domain methods (never the raw Drizzle client or HTTP client)
// classes/services/example-item/index.ts
import 'server-only';
class ExampleItemService {
async getAll(): Promise<ExampleItem[]> {
return getDb().select().from(exampleItemsTable);
}
async create(input: ExampleItemInput): Promise<ExampleItem> {
const [row] = await getDb()
.insert(exampleItemsTable)
.values(input)
.returning();
return row;
}
}
export const exampleItemService = new ExampleItemService();When do you need a service class?
Not every external call earns a service class. The deciding question is is
there a server boundary to own? - auth headers, a secret token, a credentialed
base URL, or response shaping. If there is, the call belongs in
classes/services/<domain>/. If there isn't, it doesn't.
| Call type | Example | Where it lives | Error story |
|---|---|---|---|
| Public, no-auth read | An open GET, no secret | client hook → SWR → global fetcher | generic Error, surfaced by SWR |
| Authed / secret-bearing | A credentialed API client | classes/services/<domain>/, server-only | typed ApplicationError at the boundary |
The posts demo (classes/services/posts/) is a deliberate exception to
this rule. JSONPlaceholder is a public, no-auth GET, so in real life it
would use the client → SWR → fetcher path and no service class at all. It is
wired through a full service + route handler purely so the template can
demonstrate the integration pattern end to end.
Integration errors
Where a DB service lets Drizzle errors bubble untouched, an integration
service (e.g. third party API wrapper) throws a typed ApplicationError
// classes/services/posts/index.ts
import 'server-only';
import { ApplicationError } from '@/classes/errors';
class PostsService {
async getAll(): Promise<Post[]> {
const response = await fetch(JSONPLACEHOLDER_POSTS_URL);
// fetch doesn't throw on 4xx/5xx - inspect and throw deliberately.
if (!response.ok) {
throw new ApplicationError('Failed to fetch posts', {
scope: 'posts',
function: 'PostsService.getAll',
originalError: `${response.status} ${response.statusText}`,
});
}
return (await response.json()) as Post[];
}
}See Error Handling → ApplicationError for the full contract.
Module-scoped instances vs factory
It's worth being precise about what "module-scoped instance" means in this context. The term is used here to provide a more technically accurate label for what is essentially the singleton pattern.
Next.js compiles API routes and server actions into separate bundles, so exampleItemService in a route handler
and exampleItemService in a server action are actually two different objects at
runtime. So what you end up with is not a true singleton in the classical sense. You actually get one instance per bundle, not one instance per app.
From a purely syntactical perspective though the pattern of the code is a singleton.
For stateless services this doesn't matter at all, both instances behave identically because there's no state to share. However you should never put mutable state on a service instance. An in-memory cache, a counter, or any mutable field on the class would be silently doubled. If you need shared state across bundles it belongs in the database or an external store like Redis.
The singleton pattern (module-scoped instances) is the default for service instantiation. There are two forms depending on whether construction has side effects:
-
Eager singleton - The default.
export const x = new X(). Right for any stateless service where every caller needs the same thing:export const exampleItemService = new ExampleItemService(); export const hubspotService = new HubspotService(process.env.HUBSPOT_API_KEY); -
Lazy singleton - Use only when construction has side effects that must be deferred past import time (creating directories, opening file handles).
ApplicationLoggeruses this because its constructor creates Winston file transports:class ApplicationLogger { private static instance: ApplicationLogger; private constructor() { // creates file transports } /* Lazy instantiation. The instance is only created on first call, * not at import time. Subsequent calls return the existing instance. */ public static getInstance(): ApplicationLogger { if (!ApplicationLogger.instance) { ApplicationLogger.instance = new ApplicationLogger(); } return ApplicationLogger.instance; } }/* * Rather than exporting the instance directly, * a wrapper function calls `getInstance()` internally */ export const rootLogger = (logSource: LogSource): Logger => { return ApplicationLogger.getInstance().getLogger(logSource); };
Reach for the factory pattern instead when the object carries state that belongs to a single request.
The clearest real-world case is a multi-tenant app where every data access needs to be scoped to the signed-in user's company.
With a module-scoped instance there's nowhere to put the company context, you'd
end up passing companyId into every single method call, which is noisy and
easy to forget:
// Module-scoped instance - companyId has to be threaded through every call
export const reportService = new ReportService();
// At the call site - easy to forget, no compiler enforcement
const reports = await reportService.getReports(companyId);
const report = await reportService.create(companyId, input);
const deleted = await reportService.delete(companyId, id);With a factory, the context is baked in at construction:
class ReportService {
constructor(private readonly companyId: string) {}
async getReports() {
// companyId is always correct - baked in at construction
return db
.select()
.from(reportsTable)
.where(eq(reportsTable.companyId, this.companyId));
}
}
export function createReportService(companyId: string) {
return new ReportService(companyId);
}Then in a server action or route handler:
const { companyId } = await auth();
const reportService = createReportService(companyId);
const reports = await reportService.getReports();The simple rule to remember: Does every caller use the service the same way with the same config? → module-scoped instance. Does each caller need the service configured differently based on who they are or what request they're handling? → factory.
Inheritance and composition
When integrating with a third-party API, you often start with a base client that handles the cross-cutting concerns. like authentication, rate limiting, request queuing, retry logic. Individual domain clients then build on top of it.
There are two ways to do this and the choice depends on what the domain client is doing:
- Inheritance (extend the base class) - Use when the domain client needs to add new operations that
build directly on the base class's internals. The domain client
extendsthe base and gets direct access to its protected methods:
// classes/services/http-client/index.ts
export class HttpClient {
protected baseUrl: string;
protected constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
protected async get<T>(path: string): Promise<T> {
/* auth, retry, queue */
}
protected async post<T>(path: string, body: unknown): Promise<T> {
/* ... */
}
}// classes/services/payments/index.ts
export class PaymentsClient extends HttpClient {
constructor() {
super(process.env.PAYMENTS_BASE_URL!);
}
// this.post() is available directly via extends - no re-implementation needed
async createCharge(input: ChargeInput) {
return this.post('/charges', input);
}
}- Composition (hold the base class as a private member) - Use when the domain client is primarily transforming or querying data, not adding new transport-level operations. The base client is a private dependency, injected or constructed internally:
// classes/services/reporting/index.ts
export class ReportingClient {
private client: HttpClient;
constructor() {
this.client = new HttpClient(process.env.REPORTING_BASE_URL!);
}
// Delegates to the HTTP client, focuses on data transformation
async getSummary(from: Date, to: Date): Promise<ReportSummary> {
const raw = await this.client.get(`/summary?from=${from}&to=${to}`);
return transformSummary(raw);
}
}When to pick which: Favour composition by default, it's looser coupling,
and if the base class changes, only the composition wrapper is affected rather
than every subclass. Reach for inheritance only when you genuinely need to
call the base class's protected methods (this.get(), this.post()) directly
inside your own implementation. If all you need is for the base class to
handle auth and make the request, compose.