Backend code is organized by domain. Each domain in src/features/ owns its business logic, database access, schemas, and tests.
src/features/<domain>/
modules/
<use-case>/
index.ts # Module function
index.test.ts # Tests
schemas/ # Zod schemas for this module
helpers/ # Module-specific helpers
emails/ # Email templates (React Email)
db/
queries/ # Kysely read queries
mutations/ # Prisma writes, Kysely transactions
types/ # Domain types
constants/ # Domain constants
websockets/
emitters.ts # wsEmit calls for this domain
src/features/projects/
modules/
create-project/
index.ts
index.test.ts
get-project/
index.ts
delete-project/
index.ts
projects-list/
index.ts
db/
queries/
get-by-id.query.ts
get-many.query.ts
mutations/
create-project.mutation.ts
update-project.mutation.ts
delete-project.mutation.ts
No src/services/ directory. Shared infrastructure goes in src/libs/, database clients in src/db/, cache in src/cache/, middleware in src/middleware/.
Routes stay thin. Route files in src/routes/ validate input, call module functions, and return responses. Logic belongs in modules.
No try/catch. The centralized error middleware handles all errors. Throw typed errors and let them bubble up. See the error handling section below.
Use typed error classes from @backend/middleware/error:
import {
ApplicationError,
ApplicationErrorCode,
} from "@backend/middleware/error";
if (!project) {
throw new ApplicationError({
code: ApplicationErrorCode.NOT_FOUND,
message: "Project not found",
statusCode: 404,
});
}
Available error classes:
| Class | Use case |
|---|---|
ApplicationError | Domain failures (not found, bad request, invalid state) |
AuthError | Authentication/session failures |
AuthorizationError | Missing permissions |
ValidationError | Schema validation (usually thrown automatically by the validate middleware) |
The global error middleware formats these into consistent HTTP responses.