Etsub
Remote / Worldwide  
All writing
8 min readStructure That Survives · Part 3

Package by Feature, Not by Layer

Why controllers/, services/, repositories/ stops working in a NestJS codebase, what feature modules actually give you that folders cannot, and a directory tree you can copy — plus the lint rules that keep it honest.

By , Fullstack Software Engineer

Every backend repository answers one question in its top-level directory listing: what is this system for? Most NestJS repositories answer a different question: what did nest g resource generate?

Robert C. Martin put the test bluntly in Screaming Architecture (30 September 2011):

"So what does the architecture of your application scream? When you look at the top level directory structure, and the source files in the highest level package; do they scream: Health Care System, or Accounting System, or Inventory Management System? Or do they scream: Rails, or Spring/Hibernate, or ASP?" — Martin

And the corollary that people find harder to accept:

"The Web is a delivery mechanism, and your application architecture should treat it as such. The fact that your application is delivered over the web is a detail and should not dominate your system structure."

This post is the mechanical version of that argument for NestJS: what breaks under package-by-layer, what the docs actually recommend, a tree you can lift, and the enforcement that stops it rotting.

Why package-by-layer fails at scale

1. Every feature is a diff across every folder. Adding "refunds" touches controllers/, services/, repositories/, dto/, entities/. Nothing in the layout says those five files are one thing. Reviewers reconstruct the feature by hand, every time — and so does the next person deleting it.

2. There is no boundary to enforce. This is the decisive one in Nest. A folder called services/ cannot be private. A feature module can: providers are visible outside a module only if the module lists them in exports. Layer folders throw that mechanism away and leave you with a DI container where everything can reach everything.

3. Names stop carrying information. UserService, UserController, UserRepository, UserModule, UserEntity, UserDto — six files whose names are the same noun plus the folder they are in. Compare billing/domain/invoice.ts, billing/api/invoice.controller.ts: the path carries the role, so the filename can carry meaning.

4. Circular imports become structural. Layer folders import each other in both directions the moment two features interact — OrderService needs UserService, which needs NotificationService, which needs orders. Nest papers over this with forwardRef(), which is a load-bearing warning sign, not a solution.

The counter-argument is real and worth stating: for a service under a few thousand lines with one team, layer folders are fine and everybody knows where everything is. Structure is a cost you pay to make change cheap. Pay it when change is expensive.

What NestJS actually recommends

The docs are explicit that modules are the organising unit, not a formality:

"A feature module organizes code that is relevant to a specific feature, helping to maintain clear boundaries and better organization." — NestJS, Modules

The same page notes that modules are singletons by default and that sharing happens through exports — which is exactly the encapsulation a services/ folder cannot give you. The rationale given for grouping CatsController with CatsService is that they "are closely related and serve the same application domain."

The Node community's most-cited practice repo says it more strongly: structure the solution by self-contained components, each with its own API, domain logic and data access, consumed only through public interfaces — because otherwise change cycles couple, and modifying a small part means rebuilding and redeploying everything (Node.js Best Practices, Project Structure).

A tree that screams the domain

src/
├── main.ts
├── app.module.ts                      # imports feature modules; owns nothing itself
├── billing/                           # ← bounded context
│   ├── billing.module.ts              # composition root: binds ports to adapters
│   ├── api/
│   │   ├── invoice.controller.ts
│   │   ├── dto/pay-invoice.dto.ts     # class-validator lives HERE, at the edge
│   │   └── invoice.presenter.ts       # domain -> response shape
│   ├── application/
│   │   ├── pay-invoice.usecase.ts
│   │   └── on-payment-failed.handler.ts
│   ├── domain/
│   │   ├── invoice.ts                 # no decorators, no framework imports
│   │   ├── invoices.port.ts           # interface + Symbol token
│   │   ├── money.ts
│   │   └── errors.ts
│   ├── infrastructure/
│   │   ├── typeorm/invoice.orm-entity.ts
│   │   ├── typeorm/invoice.mapper.ts
│   │   ├── typeorm/typeorm-invoices.repository.ts
│   │   └── stripe/stripe.gateway.ts
│   └── test/
│       ├── invoice.spec.ts            # domain unit tests, no Nest
│       └── pay-invoice.e2e-spec.ts
├── catalog/
├── identity/
└── shared/
    ├── config/                        # typed ConfigModule, validated at boot
    ├── logging/
    ├── database/                      # DataSource/PrismaService, exported
    └── http/                          # global filters, interceptors, guards

Properties worth noticing:

  • The context is the widest unit. Layers exist inside it — Fowler's rule from PresentationDomainDataLayering: "once any of these layers gets too big you should split your top level into domain oriented modules which are internally layered" (source).
  • Adapters are grouped by dependency. One folder per external thing — typeorm/, stripe/, s3/. Replacing Stripe means deleting one directory and rebinding one provider.
  • Tests ship with the feature. rm -rf src/billing should take the whole thing: code, mappers, DTOs, specs. That deletion test is the definition of a boundary that works.
  • shared/ is technical only. The moment a business rule lands there, it belongs to a context. shared/ drifting into utils/ is how these layouts rot — a shared/helpers/order-utils.ts is a context nobody has named yet.

Naming falls out for free. In the tree above, invoice.ts is the aggregate, invoice.controller.ts is the delivery mechanism, typeorm-invoices.repository.ts is one implementation of a port. Nobody has to write InvoiceServiceImpl.

app.module.ts stays boring

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
    DatabaseModule,
    BillingModule,
    CatalogModule,
    IdentityModule,
  ],
})
export class AppModule {}

If AppModule grows providers and controllers of its own, features are leaking upward. That file should read like a table of contents for the business.

When one app is not enough: monorepo mode

Nest has first-class support for splitting a workspace, and the docs are clear that this is a packaging decision, not an architectural one:

"Virtually all of Nest's features are independent of your code organization mode. The only effect of this choice is how your projects are composed and how build artifacts are generated." — NestJS, CLI: Workspaces

Two project types: applications, "complete Nest applications that you can run and deploy" (nest generate app), and libraries, "packages of Nest components that need to be composed into applications in order to run" (nest generate library) — a library has no main.ts and cannot run alone.

apps/
  api/                     # HTTP delivery
  worker/                  # queue consumers — same contexts, different entrypoint
libs/
  billing/src/             # the context as a library: domain + application + infra
  catalog/src/
  shared/src/
nest-cli.json              # "projects" metadata exists only in monorepo mode

This is the same layout as the single-app tree, promoted one level. Move to it when two deployables need the same contexts — an API and a worker — not because the repo "feels big". The cost is real: shared tsconfig paths, slower builds, and a build graph to reason about.

Enforcement, or it rots

Conventions decay silently. Make the boundary a failing build.

Plain Nest repoimport/no-restricted-paths defines zones, where "target identifies which files are part of the zone" and "from identifies folders from which the zone is not allowed to import" (eslint-plugin-import):

'import/no-restricted-paths': ['error', {
  zones: [
    // Contexts talk through modules, never through each other's internals.
    { target: './src/billing', from: './src/catalog', except: ['./src/catalog/catalog.module.ts'] },
    { target: './src/catalog', from: './src/billing', except: ['./src/billing/billing.module.ts'] },
    // The Dependency Rule, mechanised.
    { target: './src/*/domain', from: './src/*/infrastructure' },
    { target: './src/*/domain', from: './src/*/api' },
  ],
}]

Nx workspace@nx/enforce-module-boundaries "checks TypeScript imports and package.json dependencies during linting", using tags and depConstraints; the docs' own example is that "projects tagged with 'scope:admin' can only depend on projects tagged with 'scoped:shared' or 'scope:admin'" (Nx docs):

depConstraints: [
  { sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
  { sourceTag: 'scope:catalog', onlyDependOnLibsWithTags: ['scope:catalog', 'scope:shared'] },
  { sourceTag: 'type:domain',   onlyDependOnLibsWithTags: ['type:domain'] },
]

Either way the rule is machine-checked, which means new contributors learn the architecture from a lint error instead of a wiki page nobody reads.

The migration path (no big-bang rewrite)

Moving an existing layer-first Nest repository:

  1. Pick the feature with the fewest inbound imports. Create src/<context>/ with api/, application/, domain/, infrastructure/.
  2. Move its controller, service, repository, DTOs, entity and specs in. Fix imports. Do not refactor logic in the same commit — a pure move is reviewable in five minutes; a move plus a rewrite is not.
  3. Create <Context>Module, register it in AppModule, and export only what other contexts genuinely call.
  4. Add the lint zone for that folder only. Anything you cannot fix yet becomes an explicit except entry with a comment — a visible debt list beats an invisible one.
  5. Repeat. What is left in services/ at the end is either cross-cutting infrastructure (move to shared/) or a context nobody has named — naming it is the valuable part.

Progress metric: how many top-level folders a single feature change touches. Start at five, finish at one.

Sources