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:
interface Module<TProviders, THandlers, TImports> {
providers: TProviders;
handlers: THandlers;
// TImports is type-level only, not present at runtime
}| Field | Purpose |
|---|---|
providers | Things the module creates and owns: clients, repositories, service instances |
handlers | The module's public API: CQRS commands and queries consumed by routers and resolvers |
TImports | Type-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:
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.
// 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:
| Interface | Purpose |
|---|---|
XxxProviders | The infrastructure adapters the module creates (repositories, clients) |
XxxHandlers | The CQRS commands and queries the module exposes |
XxxImports | What other modules this module depends on |
// 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:
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.
// 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:
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:
// 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:
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.
// 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:
XxxProvidersinterfaceXxxHandlersinterface (omit if no handlers)XxxImportsinterface (omit if no imports)XxxModuleinterface extendingModule<...>xxxModulefactory function typed asModuleFn<XxxModule>xxxGraphql(ModuleGraphQL) and/orxxxRoutes(ModuleRouter) if the module has a transport surface
- The factory function name matches the file name:
library.module.tsexportslibraryModule - Foundations always have
handlers: {} - Domain handlers are CQRS classes with a single
execute()method
Adding a new module
- Create the module file in the appropriate layer (
foundations/ordomains/) - Define
XxxProviders,XxxHandlers, and optionallyXxxImports - Define
XxxModuleextendingModule<XxxProviders, XxxHandlers, XxxImports> - Implement the factory as
const xxxModule: ModuleFn<XxxModule> = (imports) => ({ ... }) - Export
xxxGraphqland/orxxxRoutesif the module has a transport surface - Wire the module into
appModules()incontainer.ts - Expose its handlers in
appContainer()if REST or GraphQL handlers need them - Call
start()inapp.tsif the module implementsModuleStartup