DDD-lite in TypeScript backends without endless boilerplate
DDD-lite in TypeScript backends keeps validation, domain rules, and transport code apart so teams can ship faster without layer sprawl.

Where backends get tangled
Most TypeScript backends start out readable. Then a controller picks up a little extra work. It parses input, checks a few rules, writes to the database, and shapes the response. That feels harmless on the first couple of routes. A month later, the same pattern shows up everywhere, with slightly different checks in each file.
One request handler ends up doing too much at once. It checks missing fields, trims strings, loads related records, blocks invalid states, writes data, and builds JSON. None of those jobs are wrong. The problem is putting them all in one place. Every small change starts pulling on everything else.
That is when duplication starts. A controller rejects an empty email. A service checks it again. A model helper adds a third version that almost matches the first two. Tests get brittle because you have to hit the whole stack to prove one rule. Refactors get risky because changing a response shape can change business behavior by accident.
Shortcuts spread fast. If one controller reaches straight into the database and mixes business checks into the route, the next controller will copy it. Before long, rules are buried in handlers, validators know too much about storage, and transport code decides business outcomes. Even a small rename turns into a search through request schemas, service functions, and random conditionals.
A lighter DDD style fixes most of this without turning the codebase into a theory project. The split is practical: runtime validation checks incoming data, domain code decides what is allowed, and transport code deals with HTTP details such as status codes and JSON. When each part has one job, the code gets easier to test, easier to move, and less annoying to change.
The three jobs to separate
Backends usually mix up three different kinds of work: checking input, applying business rules, and speaking to the outside world. When one file tries to do all three, bugs pile up fast.
Runtime validation deals with raw data. It answers plain questions: did the client send an email, is inviteCode a string, is age a number. This layer should not decide whether someone may sign up. It should only turn unknown input into trusted data, or reject it early with a clear error.
Domain rules decide what the business allows. They answer different questions: can this customer use this coupon, can a team exceed five seats, can an expired invite still work. These rules should not care about HTTP status codes, JSON bodies, queue headers, or webhook retries. They should behave the same way whether the call came from an API request, a background job, or a message consumer.
Transport code sits at the edge. It reads an HTTP request, job payload, or event, calls validation, passes clean data to a use case, and maps the result back into the format the caller expects. This layer should stay thin. If a controller starts checking subscription limits or parsing ten fields by hand, it is already doing too much.
A signup flow makes the split easy to see. Validation checks that the email exists and that the invite code is a string. The domain layer decides whether the invite exists, whether someone already used it, and whether it belongs to the right workspace. The transport layer turns those outcomes into 400, 409, or 201.
Keep each job small enough to test on its own. Validation tests use messy input. Domain tests use clean objects and business cases. Transport tests check request and response mapping. That saves time because each test asks one question instead of five.
A project shape that stays sane
Things get messy fast when request parsing, business rules, and database work live in the same file. A modest folder structure is usually enough to keep the boundaries clear:
transport/for controllers, route setup, and response mappingapplication/for use cases such ascreateUseroracceptInvitedomain/for entities, value objects, and business rules that do not depend on Express, Fastify, or a database driverinfrastructure/for repositories, queues, email providers, and other external code
This works because plain TypeScript modules sit in the middle. A use case should not care whether the request came from REST, a job queue, or a CLI command. It should receive normal data, call domain code, save changes through interfaces, and return a result.
Keep domain objects away from request and response types. A SignupRequestBody is not the same as a User, and a UserResponse is not your domain model with different property names. When those types blur together, transport concerns leak into places where they do not belong.
Runtime schemas belong at the input boundary. Parse and clean incoming data before it enters the application layer. After that, domain code should work with trusted values and enforce business rules such as "invite code must belong to the same workspace" or "a suspended user cannot create a project".
This saves real time later. When the team adds another entry point, maybe an admin tool or a background worker, it can reuse the same use case instead of copying logic out of an old controller and fixing it twice.
One request flow, step by step
A good request flow feels boring. That is a good sign.
The controller should deal with HTTP details and very little else. It reads params, query values, headers, and the JSON body. Then it passes that raw input into one validation step.
Validation checks shape and types, trims strings, fills simple defaults, and returns plain values the rest of the code can trust. After that, the use case should never see an Express, Fastify, or Nest request object.
The flow is straightforward:
- The controller receives the request and pulls out only the fields the action needs.
- Validation rejects bad input early and normalizes accepted input once.
- The use case runs on plain objects and primitives.
- Domain rules run before any write or side effect.
- The controller maps the result back to HTTP.
The line between validation and domain rules matters more than many teams expect. Validation asks, "Does this data have the right shape?" Domain rules ask, "Can this action happen right now?" A date can parse correctly and still break a policy. An email can be valid and still fail because another user already claimed it.
Put storage and side effects after the rules pass. Save the record, send the email, or publish the event only when the use case says yes. That order prevents half-finished work and ugly cleanup.
The final mapping step is small, but teams often get it wrong. Expected failures should turn into clear responses such as 400, 404, or 409. Unexpected failures should go to logs and return 500 without leaking internal details.
When teams keep this flow strict, files stay smaller and bugs are easier to find. The controller speaks HTTP, the validator cleans input, the use case decides, and the response mapper speaks HTTP again.
A realistic example: signup with an invite code
Take a signup endpoint with an invite token.
The controller reads the request body and checks the raw facts first: the email has a valid shape, the password meets the minimum length, and the invite token matches the format your app expects. If any of that fails, the request stops there with a 400 response and field errors.
That first check should stay boring. It should not ask whether the invite exists, whether the workspace is full, or whether this person is allowed to join. Those are domain questions.
Inside the signup use case, the app loads the invite and the workspace, then applies business rules in plain language. Is the invite still active? Was it already used? Does this workspace allow this type of member? If the invite was sent to a specific email address, does the new user match it?
The same use case should also handle the state change. It hashes the password, creates the user record, attaches the user to the workspace, and marks the invite as used. Repository calls belong here too, close to the rules they support. If you scatter writes across controllers and helpers, the flow gets hard to trust.
The result can stay small. You do not need to return half the domain model to the controller. A success with a user ID, or a failure reason such as invite_not_found, invite_used, or email_mismatch, is often enough.
Then the controller finishes the job. It turns that result into HTTP. Success becomes a JSON response with the created account data. Known failures become clear status codes and short messages. That keeps validation, domain rules, and transport concerns apart without piling on boilerplate.
Where validation ends and domain rules start
This is the boundary teams blur most often.
Runtime validation should stop bad input at the door. Missing fields, wrong types, empty strings, bad enum values, and malformed dates belong here. These checks answer one question: can the server safely read this payload?
Domain rules start after the data already has a clean shape. "This invite code has expired", "this plan cannot add another admin", and "a suspended user cannot create a project" are business decisions. They depend on your model, current state, and often the database. Put them in the use case or a domain service, not in the schema file.
Names matter too. Transport code often uses request names such as invite_code, user_email, query params, and headers. Your domain probably wants inviteCode and email. Convert those once near the controller, then stop dragging HTTP names through the rest of the app. That one mapping step removes a lot of confusion.
A good rule of thumb is simple. The controller reads the request and runs runtime validation. A mapper builds one input object for the use case. The use case applies business rules and calls repositories.
Take signup again. The raw request may include headers, body fields, and route params. The use case should not know any of that. It should receive one plain input shape such as email, password, and inviteCode. Once you do that, your domain code stops caring whether the data came from JSON, a queue message, or a CLI command.
If a rule needs database access or depends on current business state, it is not transport validation. Keep that line firm. It saves a lot of time later, especially when endpoints change but the rules stay the same.
Mistakes that create busywork
Busywork usually starts when a team tries to make this style look more formal than it needs to be. Clear boundaries help. Ceremony does not.
One common mistake is creating a DTO for every tiny hop. The request body becomes CreateUserDto, then CreateUserInput, then CreateUserCommand, even though all three shapes are identical. If the meaning did not change, keep one shape. Create a new type only when you actually translate from transport data to domain data, or from domain data to storage data.
Another mistake is letting domain objects read Express or Fastify objects. An entity should not know about req, headers, cookies, or framework reply methods. That ties your rules to one web layer and makes tests annoying. Pass plain values instead: actorId, email, inviteCode, now.
Repeating the same rule in three places is another easy way to waste time. Teams often check email format in a schema, repeat it in a service, and add another version in a database hook. Give each rule one home. Validation checks shape, type, and plain format. Domain code checks business rules and state changes. The database protects storage facts such as uniqueness and foreign keys.
Deep folder trees create a different kind of drag. If you have controllers, handlers, use-cases, services, actions, and mappers, someone will spend half the day opening files that only forward imports. Smaller codebases usually do better with a flatter structure and file names that say exactly what they do.
Generic base classes cause the same problem. BaseService<T>, BaseRepository<T>, and abstract handlers often hide simple code behind inheritance. You save a few lines early, then lose time every time you debug. Plain functions and small classes are easier to read, easier to change, and usually easier for AI-assisted coding tools to edit safely.
A useful filter is this: if a file does not hold a rule, a translation, or a side effect, it may not need to exist.
Quick checks for each new endpoint
A new endpoint should be boring to read. If one controller takes more than a minute to understand, it probably does too much.
You do not need a full architecture review to catch that early. A few questions are enough:
- Can someone read the controller quickly and explain what it does?
- Do you reject bad raw input before the use case starts?
- Can the domain rule run with plain values and no HTTP request or response objects?
- Could a queue worker, cron job, or CLI command call the same use case without a rewrite?
- Do tests cover one happy path and one rule that blocks the action?
These checks work because they map to separate jobs. The controller should translate transport details. Validation should reject malformed data such as missing fields, wrong types, or bad enum values. The use case should decide whether the action is allowed.
Use the signup example again. The controller reads JSON and headers. Validation confirms that email, password, and inviteCode exist and have the right shape. The use case decides whether the invite is still active, whether the email is already taken, and whether signup is open for that account type. The response mapper turns the result into status codes and JSON.
If your domain code needs req.body, res.status, or a framework context object, the boundary is already blurred. The same issue shows up when a worker cannot reuse the signup logic and you end up copying rules into a second path. That copy usually drifts within days.
Tests tell you whether the split is real. One test should show that valid input reaches the use case and returns the expected result. Another should show that a domain rule blocks the action even when the input shape is correct.
For most teams, that is enough. Clear boundaries beat fancy patterns every time.
Next steps for a live codebase
This style works best as cleanup, not as a rewrite. If your app already ships, do not stop the team and redraw everything. Pick one noisy endpoint, usually the one that breaks often, keeps growing new conditionals, or slows reviews down.
Keep the outside stable while you clean the inside. The request shape, response shape, status codes, and error messages should stay the same unless you have a strong reason to change them. Users and frontend code should not feel your refactor.
A small sequence works well. Add a few tests around the current endpoint behavior. Move request parsing and runtime checks into one validator. Move one business rule into a domain function. Leave database calls where they are for the moment if that keeps the change small. Delete the old logic only after the new path matches the old result.
That order matters. Teams get stuck when they try to move everything at once. A controller with 200 lines can get much easier to reason about after you remove just one rule, such as who can approve a refund or when an invite code expires.
ORM hooks need the same treatment. If a hook checks a business rule, copy that rule into a plain domain function first. Call that function from the application flow, prove it works, then remove the hook. Hidden behavior is where bugs like to sit.
Keep the change small enough to finish
A good first refactor takes a day or two, not a month. If you need a migration plan, six new folders, and three meetings, the slice is too big. Smaller changes teach the team what belongs in validation, what belongs in domain code, and what should stay close to transport.
Write down each rule after you move it. A short note in the codebase is enough. After a few endpoints, the pattern becomes obvious and the next cleanup goes much faster.
If you want an outside review before touching a live backend, Oleg Sotnikov at oleg.is helps startups and small companies clean up backend structure, infrastructure, and AI-first delivery without turning the work into a long refactor project.
Frequently Asked Questions
What does DDD-lite mean in a TypeScript backend?
DDD-lite means you split three jobs and keep them small. Validation checks raw input, domain code decides what the business allows, and transport code handles HTTP details like status codes and JSON.
Where should runtime validation happen?
Keep it at the input boundary, right after the controller reads the request. Parse, trim, and normalize the payload there so the use case only receives trusted values.
What belongs in domain rules instead of validation?
Domain rules answer business questions, not shape questions. Things like an expired invite, a full workspace, or a suspended user belong in the use case or domain layer, even if they need database reads.
Should controllers talk to the database directly?
No. A controller should read the request, call validation, hand clean data to a use case, and map the result back to HTTP. Once it starts mixing queries and business checks, the file gets hard to trust.
Do I need separate DTOs for every layer?
Usually no. If CreateUserDto, CreateUserInput, and CreateUserCommand all mean the same thing, keep one shape. Add a new type only when you really translate between transport, domain, or storage.
How do I make one use case work for HTTP, jobs, and CLI commands?
Use plain objects and plain values in the application layer. If the signup logic only needs email, password, and inviteCode, a queue worker, CLI command, or background job can call the same use case without HTTP code.
How should I map domain errors to HTTP status codes?
Treat expected failures as normal results and map them on the edge. Bad input usually returns 400, missing data returns 404, business conflicts return 409, and unexpected errors go to logs and return 500 without internal details.
How can I refactor a messy endpoint without a full rewrite?
Start with one noisy endpoint, not the whole app. Add tests around current behavior, move request parsing into one validator, pull one business rule into domain code, and keep the external API the same while you clean the inside.
What should I test when I separate validation, domain, and transport?
A good split gives you focused tests. One test can prove validation rejects bad input, another can prove a business rule blocks the action, and a small transport test can check the status code and response body.
What are the usual signs that the boundary is blurred?
Watch for rules in schema files, req or res leaking into domain code, and the same check repeated in controllers, services, and hooks. ORM hooks often hide business logic too, so move those rules into plain functions you can call and test directly.