EEPNext.js Template
The Layers

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

FolderWhat 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 prevents import type from 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 typeExampleWhere it livesError story
Public, no-auth readAn open GET, no secretclient hook → SWR → global fetchergeneric Error, surfaced by SWR
Authed / secret-bearingA credentialed API clientclasses/services/<domain>/, server-onlytyped 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). ApplicationLogger uses 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 extends the base and gets direct access to its protected methods:
index.ts
index.ts
// 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:
index.ts
index.ts
// 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.

On this page