Structure a backend service
Skill kennguyen887/agent-foundation/skills/structure-a-backend-service
Claude Code skills marketplace — backend & frontend engineering conventions + step-by-step third-party integration recipes: Stripe, Rapyd, CyberSource, UOB & wallet payments, Singpass/Keycloak OIDC & 3-D Secure, Twilio SMS, Docker & CI/CD. NestJS/TypeScript + React, language-flexible.
npx -y skills add kennguyen887/agent-foundation --skill structure-a-backend-serviceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when scaffolding a backend service, adding a feature/module, or reviewing folder layout, file/class naming, DTO & entity structure, or the CQRS read/write split. Language-agnostic with TS/NestJS examples.
SKILL.md
21.1 KB, as published. Nobody here has run it
When to use
Reach for this when you are creating a new service repo, adding a feature/module to an existing one, or reviewing whether code is laid out the team way. It answers "where does this file go", "what do I name it", "how is a feature wired", "how do reads vs writes get split", and "how are tests structured".
Scope: structure, naming, and wiring — not do/don't policy (additive migrations, no process.env
in business logic, HTTP-layer tests, backward-compat), which lives in the global instructions,
cross-referenced inline; follow both.
Steps
Each convention: portable principle → ▸ Example (TS/NestJS) (neutral listing domain;
<feature> / <Entity> = rename) → ▸ Other stacks. Walk these in order when scaffolding.
0. Core principle (read first)
Organize by feature, not by layer. A feature owns its entry points, its read path, its write path, its DTOs, and its events. Keep the transport/entry layer thin — it only validates input and delegates. Centralize the domain model (persisted entities + domain types) and shared base classes so features stay about behavior. Drive everything from typed config, never raw env reads.
1. Lay out the repo
A service has one source tree split into feature modules, centralized domain models, two tiers of shared code, config factories, and schema migrations as a top-level sibling of source.
▸ Example (TS/NestJS) — copy the shape, swap the domain words:
<service-name>/ # e.g. listings-service
├── src/
│ ├── main.ts # bootstrap: global validation pipe, filters, security headers, OpenAPI
│ ├── main.module.ts # root module: imports every feature module + global providers
│ ├── config/ # one factory per concern + a consts file of typed keys
│ │ ├── db.config.ts
│ │ ├── cache.config.ts
│ │ └── consts.ts # configDb='db', configCache='cache', ... (no magic strings)
│ ├── domain/ # domain layer: persisted models + non-persisted domain types
│ │ ├── entities/ # DB tables (ORM-mapped) — base.entity.ts + <entity>.entity.ts
│ │ └── models/ # domain enums, constants, value objects & type aliases (NO ORM/DB)
│ ├── modules/ # one folder per feature (listing, search, ...)
│ │ └── <feature>/ # see step 2 for the inside of a feature
│ ├── common/ # app-wide shared business logic: services, constants, shared DTOs
│ └── shared/ # lower-level: enums, utils, external-system adapters (e.g. search/)
├── libs/ # vendored shared libraries (see "Shared libraries" below); path-alias resolved
├── migrations/ # schema changes (DDL) — sibling of src/ (see global Migration Rules)
├── seeds/ # initial/reference & test data (DML) — its own data-source + CLI, NOT migrations
├── test/ # mirrors src/modules/; factories + setup live at the root (step 8)
├── ormconfig.migration.ts # data-source used only by the migration CLI
├── .env.example # every env var, with safe placeholder (see global Config Rules)
└── package.json
- Two tiers of shared code, and the line between them matters.
common/= app-wide business building blocks (shared services, shared response DTOs, domain constants).shared/= lower-level, business-agnostic plumbing (enums, pure utils, adapters to external systems). When unsure: does it know about your domain? →common/. Could it live in any service? →shared/. - Truly cross-service code is a shared library, not a copied file. Base classes every service
needs (pagination response, ORM naming strategy, exception classes, auth guards, CQRS base,
request logger) live in shared libraries — don't re-implement them per repo. ▸ Example
(TS/NestJS): they sit under
libs/<lib-name>/(e.g.infra-common,infra-auth,infra-cqrs,infra-exception), each a package with its ownsrc/+ build tsconfig, exposed under a placeholder path-alias scope@org/*. Two things make this work and must stay in sync:- Resolved by path alias, not
npm install. The libs are NOT listed inpackage.jsondependencies; they're mapped in the compiler config (tsconfigpaths) and the test runner (e.g.jestmoduleNameMapper) — both pointing@org/<lib>→libs/<lib>/src. Add a lib → add both mappings, or build/tests break. - Vendored at a pinned version.
libs/is checked out from one central shared repo at a fixed tag (e.g. alibs:buildscript doing a meta-repo checkout), so every service runs the same version. Bump the tag deliberately; never hand-edit vendored lib code inside a service repo. ▸ Other stacks: a published internal package (pip/Maven/npm) or a git submodule pinned to a tag — the principle is one shared, version-pinned source of truth, not copy-paste.
- Resolved by path alias, not
2. Organize a feature module
Everything one feature needs lives in its own folder; reads and writes are separate files.
▸ Example (TS/NestJS) — inside src/modules/<feature>/ (here <feature> = listing):
modules/<feature>/
├── <feature>.module.ts # wires this feature: imports, controllers, providers, exports
├── <feature>.controller.ts # HTTP/REST entry (@Controller('listings'))
├── <feature>-cmd.controller.ts# transport entry (@MessagePattern) — co-located, same handlers
├── <feature>.service.ts # cross-handler domain logic / external calls (only when shared)
├── commands/ # ONE write use-case per file (create-listing.ts, publish-listing.ts)
├── queries/ # ONE read use-case per file (get-listing-list.ts)
├── events/ # domain events + their handlers (*.event.ts)
├── dto/
│ ├── inputs/ # request DTOs + index.ts barrel (see step 4)
│ └── responses/ # response DTOs + index.ts barrel (see step 4)
└── utils/ # feature-only helpers (*.util.ts)
- Split the read path from the write path (CQRS). Each use-case is its own file under
commands/(mutations) orqueries/(reads). The entry layer dispatches to a bus; it contains no business logic. This keeps each use-case independently testable and greppable. ▸ Other stacks: separatecommands/andqueries/packages of one-class-per-use-case handlers; you do not need a CQRS framework to get the benefit — the split is the point. - One file per use-case holds the request + its handler together. e.g.
create-listing.tsexportsCreateListingCommand(the input shape) andCreateListingCommandHandler(the behavior). Don't scatter a use-case across files. - Co-locate multiple transports. REST and message-based entry points for the same feature sit
side by side (
<feature>.controller.ts+<feature>-cmd.controller.ts) and both delegate to the same command/query handlers. Add a transport without duplicating logic. - A
*.service.tsis for logic shared by several handlers (or external-API orchestration), not a dumping ground. If only one handler needs it, keep it in the handler.
3. Name files and classes
Predictable names are the whole point — you should be able to guess a path.
- Files:
kebab-case. Classes:PascalCase. A file's class is the PascalCase of its name. - Role suffix on framework artifacts, none on use-cases. ▸ Example:
*.controller.ts,*.service.ts,*.module.ts,*.entity.ts,*.dto.ts,*.event.ts,*.config.ts,*.util.ts. Commands and queries take no suffix — thecommands//queries/folder already says what they are (create-listing.ts, notcreate-listing.command.ts). - Barrel files (
index.ts) per significant folder, used for clean folder-level imports and batch registration (providers: [...CommandHandlers, ...QueryHandlers]). ▸ Other stacks: a package__init__.py/mod.rs/ package export that re-exports the folder. - Import paths: shared libraries via alias (e.g.
@org/*); everything local via relative paths.
4. Define DTOs — split inputs from responses
DTOs are the typed contract at the boundary; requests and responses never share a folder.
dto/inputs/vsdto/responses/. Inputs validate incoming data; responses shape what goes out (and hide internal fields).- Every
inputs/andresponses/folder has anindex.tsbarrel re-exporting its DTOs, so consumers import in one line, not one per file:import { CreateListingRequestDto, GetListingQueryDto } from '../dto/inputs';. When you add a DTO, add itsexport * from './x.dto';to that folder'sindex.ts. ▸ Other stacks: the package's re-export file (__init__.py,mod.rs, a package index). - Naming encodes direction & cardinality. ▸ Example: inputs
Create<Entity>RequestDto,Update<Entity>RequestDto,Get<Entity>QueryDto; responses<Entity>ResDto,<Entity>ListResDto. List responses extend a shared pagination-response base. - Validate on input, whitelist + transform. ▸ Example: declarative validation decorators
(
@IsUUID,@IsEnum,@Transform) + a global validation pipe (whitelist: true, transform: true) that rejects unknown fields and turns validation errors into the standardAppBadRequestException. - Control output explicitly. ▸ Example:
@Exclude()on the class,@Expose()per field,@Type(() => Nested)for nested DTOs; map entity→DTO withplainToInstance(<Entity>ResDto, data)in the handler. ▸ Other stacks: an explicit serializer/schema (Pydantic model, Java DTO + mapper, Go struct with json tags) — never return the persistence model directly.
5. Define entities and access data
- The domain layer splits in two —
entities/vsmodels/. ▸ Example:src/domain/entities/holds ORM-mapped DB tables;src/domain/models/holds domain types that are not tables — enums/value sets (Gender,<Entity>Status), domain constants, and type aliases/projections (type <Entity>Object = <Entity>). Pure types, no ORM decorators, no DB; one concept per kebab-case file, re-exported via the folder barrel. Put low-level/technical enums inshared/enums/instead — domain-central value sets belong indomain/models/. ▸ Other stacks: adomain/package split into persisted models and plain enums/value-objects/types. - All persistence models in one place, on a shared base. ▸ Example:
src/domain/entities/(not inside features). Every entity extendsBaseEntity, which addscreatedAt/updatedAt/isDeleted(audit + soft delete) — these areselect: false, so you opt in. Soft-deleted rows are excluded with an explicitWHERE isDeleted = false. ▸ Other stacks: a base model / mixin contributing the same audit + soft-delete columns. - DB columns
snake_case, code propertiescamelCase— bridged by a naming strategy, so you never hand-name columns. ▸ Other stacks: the ORM's snake-case-to-camel mapping config. - Use the ORM's repository directly + a query builder; no custom repository wrapper layer. ▸ Example: inject the typed repository into the handler and build queries with the query builder; select only the columns you need. Push every row filter (status/type/date/soft-delete) into the query — do not fetch broadly and filter in code (this is also a global rule).
- Money/decimals go through a decimal transformer (fixed precision/scale + a decimal type), never raw floats.
- Migrations vs seeds are different tools — keep them apart. Migrations change the schema
(DDL: create/alter/drop tables & columns) or transform existing data; they are reversible
(
up/down) and immutable once merged. Seeds insert initial/reference or test data (DML). ▸ Example:migrations/with its own data-source + CLI, and a separateseeds/with its own data-source + CLI. Never seed production data inside a migration. For the full migration rules (additive-only, reversible, run on a fresh DB before commit) follow the global Database & Migration Rules — not restated here.
6. Wire the code patterns
- Thin entry layer. Controllers validate the DTO and dispatch to the matching handler; no business logic. (Matches the global "request handlers read top-to-bottom in one screenful" rule.)
- Errors: throw typed domain exceptions, translate once at the edge. ▸ Example: handlers
throw
AppBadRequestException/AppNotFoundException; a single global exception filter maps them to the HTTP/transport response. Don't format error responses inside handlers. ▸ Other stacks: a custom exception hierarchy + one global error handler/middleware. - Side effects via domain events, not inline calls. ▸ Example: a handler emits an event
(
<Entity>StatusChangedEvent); an event handler publishes outward (an external pub/sub). Keep the use-case's own logic free of fan-out. ▸ Other stacks: an event bus / outbox. - External systems behind an adapter + base service in
shared/. ▸ Example:shared/<external-system>/<system>-base.service.tscentralizes auth/URL/error handling; adapters translate domain↔external shapes. Business code calls the adapter, never the HTTP client directly. - Cross-cutting concerns are global, declared once (request logging, activity log, security headers, auth guards) — not re-added per controller.
7. Configuration
- No raw env in business code. ▸ Example: one config factory per concern in
src/config/(registerAs('<key>', () => ({...}))), keys are constants in aconfig/constsfile, read viaconfigService.get(configDb). ▸ Other stacks: a typed settings object / config service. - Per-environment files (
.env.<NODE_ENV>), and every var in.env.example+ validated at boot (global Config & Environment Rules — fail fast, no silent fallback).
8. Testing (unit & integration)
The team tests through the outermost boundary, not isolated internals — so a "unit" of behavior is verified the way it actually runs. Boot the app once, build data with factories, hit the real transport, assert response and persisted state.
- Tests mirror
src/modules/. One spec per controller/use-case undertest/modules/<feature>/; factories and the boot harness live at the test root. - Boot the app once, share a context. A single setup file builds the app from the root
module, overrides only external infra (job queue, logger) with mocks, starts the transport
- a client, and exposes a global
testContext = { app, module, client, dataSource }. An aggregator entry globs andrequires every*.spec.tsso the whole suite boots once (fast, shared DB). ▸ Example:test/setup-app.ts+ atest/<suite>.e2e.tsentry. ▸ Other stacks: a shared fixture /conftestthat starts the app + a real test DB once per run.
- a client, and exposes a global
- Exercise via the real boundary. Each test sends a real request through the transport/HTTP client and asserts both the response shape and the persisted DB row (re-read it from the repository). Re-apply the same global validation pipe the app uses in prod.
- Mock only what crosses the process boundary. Stub external service calls
(
jest.spyOn(ExternalService.prototype, 'method')) and outbound HTTP (an HTTP-intercept lib); use a real database. Never mock the unit under test. Assert outbound side effects with spy-called-with. - Factories build data; clean up per test. An
EntityFactory<T>base withmake(overrides)(random values via a faker lib) andbuild()(persist), plus aFactoryContext(dataSource)that tracks created rows and tears them down inafterEach. ▸ Other stacks: factory_boy / test-data builders + per-test truncation or a transactional rollback. - No isolated unit tests that bypass the boundary. This matches the global HTTP-layer testing
rule: if a behavior can't be reached through the boundary, skip it rather than unit-testing
internals (no
tests/services/**,tests/validators/**,tests/utils/**that bypass the route). - Coverage is collected from
src/**but excludes wiring with no logic (*.module.ts,config/*,main.ts, mocks).
9. Porting to a non-TS / non-NestJS stack
Keep the principles (left), swap the realization (right):
| Principle (portable) | Example (TS/NestJS) | Generic equivalent |
|---|---|---|
| Feature-based folders, not layer-based | src/modules/<feature>/ | one package per feature |
| Split read vs write use-cases | commands/ + queries/, CQRS bus | command/query handler packages (no framework needed) |
| Thin entry layer delegates | controller → CommandBus/QueryBus | thin handler → use-case object |
| Multiple transports, one core | *.controller.ts + *-cmd.controller.ts | HTTP + gRPC/queue adapters over shared handlers |
| Domain layer: persisted models vs domain types | domain/entities/ (+BaseEntity) & domain/models/ | persisted models + enums/value-objects |
| Inputs vs responses, validated & serialized | dto/inputs + dto/responses + global pipe | request schema + response serializer |
| Typed config, no raw env | config factories + typed keys + config service | typed settings module |
| Typed exceptions, one global translator | App*Exception + global filter | exception hierarchy + error middleware |
| Shared base code is a shared library | @org/infra-* path alias | internal shared library/module |
| Test through the boundary, boot once, factories | setup-app + transport client + factories | shared fixture + real test DB + data builders |
Verification
A module/repo follows the conventions when all of these hold:
- Layout:
find src -maxdepth 2 -type dshowsconfig/,domain/entities/,domain/models/,modules/,common/,shared/; each feature undermodules/<feature>/has its own*.module.tsand (where it has use-cases)commands/and/orqueries/. No entity files live inside a feature folder. Schema changes live inmigrations/, data inseeds/(separate data-sources); shared libs inlibs/, mapped@org/<lib>→libs/<lib>/srcin bothtsconfigand the test runner. - Read/write split: mutations live in
commands/, reads inqueries/; each use-case file holds its request type and its handler. - Naming: framework files carry their role suffix and use-case files carry none; filenames are kebab-case and each class name is the PascalCase of its file name.
- DTOs: request DTOs only under
dto/inputs/, response DTOs only underdto/responses/; no handler returns a raw entity. - Config:
grep -rn "process.env" src/ --include='*.ts' | grep -v "src/config/"returns only bootstrap files (main.ts/main.module.tsselecting the env file or port). Any hit insidemodules/,common/, orshared/is a violation — env there must be read throughconfig/. (Note the quoted'*.ts': an unquoted glob fails under zsh.) - Tests:
test/mirrorsmodules/; specs hit the real transport/HTTP boundary and assert persisted state; only external infra is mocked. No unit-test files bypass the boundary. - Gates pass: lint, build, and the test suite are green (your stack's equivalents of
pnpm lint/pnpm build/pnpm test);.env.examplelists every new var.
If porting to another language, the Verification above maps 1:1 — the folder names change, the checks (feature folders exist, read/write split exists, no raw env in business code, no model leakage in responses, tests run through the boundary) do not.
Related
- README — how project skills are organized and discovered.
- write-service-code — how to write the code inside these files (control flow, async, query performance, events/SQS, logging, tests). This skill = where & what; that one = how.
- Global assistant instructions (highest authority for the do/don't policy this skill points at): Database & Migration Rules, Config & Environment Rules, Testing Rules / HTTP-layer testing rule, Code Style — Function Size & Density, DRY — Parallel flows. This skill says where code goes and what it's named; those say what it must and must not do.