Skip to content

Domain-Driven: the part of the project the README leads with

6/7/2026Backend DevelopmentExpress Boilerplate8 min read

title: "Domain-Driven: the part of the project the README leads with" slug: proof-and-case-studies-readme-domain-driven pillar: Proof and Case Studies angle: behind-the-scenes audience: Mixed — prospects and technical evaluators status: draft

Domain-Driven: the part of the project the README leads with

Walk what Domain-Driven means in this codebase — and why it sits in the README rather than a comment buried in the folder structure.

Most Express projects are structured by technical layer: controllers/, models/, services/, routes/. The intention is clear — separate concerns by type. The problem surfaces around month three, when the auth service starts importing from the user controller, the payment model joins the tenant model, and a change to how sessions work requires touching six different folders. The README for this boilerplate leads with "Domain-Driven" because the folder structure makes an explicit bet in the other direction: each domain folder owns everything it needs, and cross-domain dependencies go in one direction only.

The output the reader sees from the outside

The top-level directory is modules/. There is no controllers/, no models/, no services/. Every feature of the application is a subdirectory of modules/, named after the domain concept it represents:

modules/
├── auth/
├── auth_impersonation/
├── auth_saml/
├── auth_sso/
├── common/
├── coupon/
├── db/
├── env/
├── limiter/
├── logger/
├── notification_inapp/
├── notification_mail/
├── notification_push/
├── notification_sms/
├── payment/
├── redis/
├── redis_idempotency/
├── setting/
├── storage/
├── tenant/
├── tenant_branding/
├── tenant_domain/
├── tenant_export/
├── tenant_invitation/
├── tenant_member/
├── tenant_session/
├── tenant_setting/
├── tenant_subscription/
├── tenant_usage/
├── user/
├── user_agent/
├── user_preferences/
├── user_profile/
├── user_security/
├── user_session/
├── user_social_account/
├── webhook/
└── ...

What a reader sees immediately: the domain model of the application is visible in the file system. You can read the business capabilities off the directory listing without opening a single file. Authentication covers four sub-domains. Tenancy covers eight. Notifications split by channel. Users split by concern. Infrastructure has its own modules. This is not accidental.

The decision tree behind the output

The choice to organise by domain rather than by layer follows from a specific question: "Who owns the behaviour of a feature — a layer, or the feature itself?" In a layer-based structure, auth behaviour lives across auth.controller.ts (routing), auth.service.ts (logic), auth.model.ts (schema) in three different folders. The auth domain does not have a single address in the codebase. In a domain-based structure, the auth feature has one folder and everything it owns is inside it.

The decision tree had roughly five nodes:

  1. What are the clear bounded contexts? Authentication is different from billing is different from notifications — they have different change rates, different owners, and different external dependencies.
  2. Should sub-contexts be separate modules or sub-folders within a parent? The answer here was separate modules — auth, auth_saml, auth_sso, auth_impersonation are siblings, not nested. This keeps each module independently deletable when a project does not need SAML.
  3. Where does infrastructure live? Separate modules — redis, db, env, logger, limiter, storage — that domain modules can import, but that have no knowledge of domain concepts.
  4. What is the internal file convention per module? Every module has the same pattern: service, DTO, types, messages, setting keys, entities. The convention is enforced by example, not by linting.
  5. How do modules communicate? Direct TypeScript imports. No event bus, no DI container at the module level. The simplicity is intentional — a static service class that imports from another static service class is traceable with cmd+click.

Walk each node with a real artifact in the repo

The auth module is the canonical example of the internal convention:

modules/auth/
├── auth.dto.ts
├── auth.dto.test.ts
├── auth.messages.ts
├── auth.otp.service.ts
├── auth.password.service.ts
├── auth.service.ts
├── auth.service.test.ts
├── auth.setting.keys.ts
├── auth.totp.service.ts
└── dictionaries/

One folder. The DTO (input validation), the messages (error strings, typed), the services (OTP, TOTP, password, and the main orchestrator), the setting keys (what the module reads from the global settings store), and the tests. When you need to understand how OTP login works, you open modules/auth/. When you need to add an auth config key, you open modules/auth/auth.setting.keys.ts. Nothing about auth lives elsewhere.

The setting keys file illustrates how configuration ownership works:

// modules/auth/auth.setting.keys.ts
export const AuthSettingKeySchema = z.enum([
  'allowRegistration', 'emailVerificationRequired', 'sessionDuration', 'maxLoginAttempts',
  'ssoAllowedProviders',
  'jwtAccessTokenSecret', 'jwtAccessTokenExpiresIn',
  'jwtRefreshTokenSecret', 'jwtRefreshTokenExpiresIn',
  'oauthGoogle', 'oauthGitHub', 'oauthMicrosoft', 'oauthLinkedIn',
  'oauthApple', 'oauthTwitter', 'oauthMeta', 'oauthAutodesk',
  'googleClientId', 'googleClientSecret',
  'githubClientId', 'githubClientSecret',
  // ... additional OAuth provider keys
]);
export type AuthSettingKey = z.infer<typeof AuthSettingKeySchema>;
export const AUTH_KEYS = AuthSettingKeySchema.options;

Thirty-plus configuration keys — all typed, all declared in the auth module itself. No global config.ts that aggregates settings from everywhere. When someone asks "what does the auth module need to run?", the answer is in auth.setting.keys.ts. This is domain ownership taken to the configuration level.

Infrastructure modules follow the same pattern but are explicitly separated:

modules/redis/
├── index.ts
├── redis.bullmq.ts
└── redis.service.ts

Three files. No DTO, no messages, no entities — because Redis has no business logic to encapsulate, only infrastructure. The module exports exactly what the domain modules need: a singleton connection and a factory for Pub/Sub connections. Domain modules import from @/modules/redis; they do not import from ioredis directly.

The thing that almost went differently

The alternative was a hybrid: domain folders for services and types, but a separate shared entities/ folder for all TypeORM or Prisma entities. The argument for it is familiar: entity definitions span domains (a user has sessions, payments, social accounts) so shared ownership of the schema makes sense.

The counter-argument that won: entity co-location. user_security owns the entities related to security (passkeys, security settings). user_session owns session entities. user_profile owns profile data. Each module knows its own schema. Cross-module joins happen at the service layer, not at the entity layer. A user entity might have a userId field that a payment entity references — but the payment entity is in modules/payment/entities/, not in a global entities/ folder that every module implicitly depends on.

The consequence: changing the user security schema requires touching only modules/user_security/. Nothing outside that module needs to know that a column changed unless the service interface changed.

What you would change if starting over

The boundary between user, user_security, and user_session is the one place where the module boundaries feel slightly arbitrary. A user_security service can import user entities directly. user_session imports from both. In practice, these three modules form a single bounded context — the user identity domain — but they are three folders with three separate service files.

A cleaner model might have been a single user_identity/ module with sub-folders for security, sessions, and profile data. The reason this did not happen initially is that the sub-concerns genuinely have different change rates: auth session logic changes when you change the JWT strategy; passkey logic changes when you update the WebAuthn spec version; profile logic changes for UI reasons. Splitting them means a security change does not touch session code even when they share the same user entity.

Neither structure is obviously right. The current one errs toward over-splitting. The alternative errs toward under-splitting. The former produces more import paths; the latter produces service files that are harder to scan.

CTA

If you are starting a TypeScript/Express project and using a layer-based structure, do a one-time experiment before committing: take the most complex feature in scope (auth, or whatever has the most external dependencies) and write down how many folders a change to its core behaviour would touch. If the answer is more than two, consider whether a domain folder would give the same change a single address.

Trade-off

Domain-based organisation trades discoverability for navigability. A developer new to the codebase who looks for "how database connections work" will find it in modules/db/. A developer who looks for "how users log in" will find it in modules/auth/, not scattered across controllers/, models/, and services/. The trade-off: there is no canonical place where all services are listed, and no file that shows how domain modules relate to each other — you derive that from import graphs or from reading the router. New engineers who expect a controllers/ folder will not find one.

Business impact

The business consequence of domain-based organisation is in the cost of extending the application. When a new module needs to be added — say, a subscription_billing module for a new client requirement — the developer creates a single folder, writes the service, DTO, and types there, and wires it into the router. Nothing else changes. The existing modules do not know the new module exists unless they explicitly import from it. That isolation keeps scope changes predictable: adding a feature does not require understanding the full codebase, only the module being changed and its direct dependencies.

For clients who will eventually hand off the codebase to an internal team, a domain-organised structure is significantly easier to document. "Here is the auth module, here are its setting keys, here is where you change the session duration" is a complete handoff instruction for that feature. A layer-based handoff requires explaining the interaction graph of five separate folders.

What to do next

Open a project you currently work on and count how many folders you need to navigate to fully understand one end-to-end feature — login, for example, or file upload. If the answer is more than three, the feature does not have a single address. That is not necessarily a problem, but it is worth naming before the codebase grows to forty modules and the navigation cost compounds.

The module pattern in this boilerplate is directly portable — the conventions (service, DTO, messages, setting keys, tests) are not framework-specific. You can apply them to an existing project incrementally by starting with the next new feature rather than refactoring existing code.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox