Skip to content

Modules

API services use a module pattern to compose foundations and domains into a running application. Modules are plain functions that return objects. There are no classes, decorators, or dependency injection containers.

Intent

The goal is to:

  • Make the dependency graph explicit and visible in one place
  • Keep each domain isolated behind a narrow public surface
  • Allow foundations (infrastructure clients) and domains (business logic) to be wired together through function composition
  • Avoid hidden global state or service locators

The Module interface

Every module, whether a foundation or a domain, conforms to the same Module<TProviders, THandlers, TImports> interface from @repo/core-server:

ts
interface Module<TProviders, THandlers, TImports> {
	providers: TProviders;
	handlers: THandlers;
	// TImports is type-level only, not present at runtime
}
FieldPurpose
providersThings the module creates and owns: clients, repositories, service instances
handlersThe module's public API: CQRS commands and queries consumed by routers and resolvers
TImportsType-level declaration of what other modules this module depends on

The TImports generic is encoded at the type level only. There is no runtime import slot, it exists purely so ModuleFn can enforce the correct argument shape.

Namespace utilities

Module.Providers<T>, Module.Handlers<T>, and Module.Imports<T> extract individual parts of a module type. These are useful when composing modules or writing utilities:

ts
type BookHandlers = Module.Handlers<BookModule>;
// → { search: SearchBooksQuery; reserve: ReserveBookCommand }

Two layers

Modules are split into two layers: foundations and domains.

Foundations

A foundation wraps an external client or infrastructure concern (database, message broker, third-party SDK). It is created with a zero-argument factory and exposes only what the rest of the application needs through providers.

ts
// foundations/email/email.module.ts

export interface EmailProviders {
	client: IEmailClient;
}

export interface EmailModule extends Module<EmailProviders> {}

export const emailModule = (): EmailModule => ({
	providers: {
		client: new EmailClient({
			apiKey: Env.EMAIL_API_KEY,
			region: Env.EMAIL_REGION,
		}),
	},
	handlers: {},
});

Foundations never depend on other modules. They only read from environment configuration and expose a providers object. The handlers field is always an empty object {}.

Domains

A domain module wires repositories and services together, declares providers for its infrastructure adapters, and handlers for its CQRS commands and queries. The handlers are the public-facing API consumed by routers and resolvers.

Declare three interfaces per domain module:

InterfacePurpose
XxxProvidersThe infrastructure adapters the module creates (repositories, clients)
XxxHandlersThe CQRS commands and queries the module exposes
XxxImportsWhat other modules this module depends on
ts
// domains/library/library.module.ts

export interface LibraryProviders {
	books: BookRepository;
	members: MemberRepository;
}

export interface LibraryHandlers {
	searchBooks: SearchBooksQuery;
	reserveBook: ReserveBookCommand;
	getMembers: GetMembersQuery;
}

export interface LibraryImports {
	database: DatabaseModule;
	email: EmailModule;
}

export interface LibraryModule extends Module<LibraryProviders, LibraryHandlers, LibraryImports> {}

Then implement it with a ModuleFn:

ts
export const libraryModule: ModuleFn<LibraryModule> = (imports): LibraryModule => {
	const books = new PostgresBookRepository(imports.database.providers.pool);
	const members = new PostgresMemberRepository(imports.database.providers.pool);

	return {
		providers: { books, members },
		handlers: {
			searchBooks: new SearchBooksQuery(books),
			reserveBook: new ReserveBookCommand(books, members, imports.email.providers.client),
			getMembers: new GetMembersQuery(members),
		},
	};
};

CQRS handlers

Handlers are the only public surface of a domain module. Each handler extends Query or Command from @repo/core-server/cqrs and implements a single execute() method. Handlers receive their dependencies through their constructor and do not hold request-level state.

ts
// domains/library/shell/cqrs/queries/search-books.query.ts
import { Query } from "@repo/core-server/cqrs";

export declare namespace SearchBooksQuery {
	export type Input = SearchBooksInput;
	export type Output = Promise<Book[]>;
}

export class SearchBooksQuery extends Query<SearchBooksQuery.Input, SearchBooksQuery.Output> {
	constructor(private readonly books: BookRepository) {
		super();
	}

	execute = async (input: SearchBooksQuery.Input): SearchBooksQuery.Output => {
		return this.books.search(input);
	};
}

Handlers are instantiated once when the module is created and reused across requests. They are accessible via the app container.

ModuleFn

ModuleFn<TModule> is the type for a module factory function. It receives the imports the module declared and returns a fully wired module instance:

ts
type ModuleFn<TModule> = (imports: Module.Imports<TModule>) => TModule;

Using ModuleFn as the type for the factory function ensures TypeScript validates that the correct imports are provided at the call site.

GraphQL and REST surfaces

A domain module's transport surfaces are declared outside the Module instance, as standalone exports:

ts
// GraphQL
export const libraryGraphql: ModuleGraphQL<Resolvers> = {
	typeDefs: [libraryTypeDefs],
	resolvers: libraryResolvers,
};

// REST
export const libraryRoutes: ModuleRouter = async (fastify) => {
	await fastify.register(
		async (r) => {
			await r.register(reservationRouter, { prefix: "/reservations" });
		},
		{ prefix: "/library" },
	);
};

These are collected in container.ts and registered in app.ts.

ModuleStartup

Some modules need to connect to an external system before the app can serve traffic. Implement ModuleStartup to expose a start() method:

ts
export interface DatabaseModule extends Module<DatabaseProviders>, ModuleStartup {}

export const databaseModule = (): DatabaseModule => {
	const pool = createPool({ url: Env.DATABASE_URL });
	return {
		providers: { pool },
		handlers: {},
		start: async () => {
			await pool.connect();
		},
	};
};

The app calls start() explicitly during boot. See App Container for where startup is triggered.

Composition root

All modules are assembled in container.ts. This is the only place where the full dependency graph is visible. Foundations are created first, then domains are created by passing in their dependencies.

ts
// app/container.ts

export function appModules() {
	// Foundations
	const database = databaseModule();
	const email = emailModule();

	// Domains
	const library = libraryModule({ database, email });
	const events = eventsModule({ database });

	return { database, email, library, events };
}

See App Container for how appModules is used at request time and during startup.

Conventions

  • Foundation modules live under src/foundations/<name>/
  • Domain modules live under src/domains/<name>/
  • Each module file exports:
    • XxxProviders interface
    • XxxHandlers interface (omit if no handlers)
    • XxxImports interface (omit if no imports)
    • XxxModule interface extending Module<...>
    • xxxModule factory function typed as ModuleFn<XxxModule>
    • xxxGraphql (ModuleGraphQL) and/or xxxRoutes (ModuleRouter) if the module has a transport surface
  • The factory function name matches the file name: library.module.ts exports libraryModule
  • Foundations always have handlers: {}
  • Domain handlers are CQRS classes with a single execute() method

Adding a new module

  1. Create the module file in the appropriate layer (foundations/ or domains/)
  2. Define XxxProviders, XxxHandlers, and optionally XxxImports
  3. Define XxxModule extending Module<XxxProviders, XxxHandlers, XxxImports>
  4. Implement the factory as const xxxModule: ModuleFn<XxxModule> = (imports) => ({ ... })
  5. Export xxxGraphql and/or xxxRoutes if the module has a transport surface
  6. Wire the module into appModules() in container.ts
  7. Expose its handlers in appContainer() if REST or GraphQL handlers need them
  8. Call start() in app.ts if the module implements ModuleStartup