Project architecture
How to organise backend code so it survives growth, new clients and new developers.
In 30 seconds
Architecture is how you split code into parts with clear jobs: what handles HTTP, where business rules live, how data is stored. Good structure lets you add a mobile app, an admin panel or a new team member without rewriting everything. The core ideas are layers, services, DTOs, validation at the edges and modules per domain.
Why I use it
- I care about architecture because one backend often serves a website, a mobile app and an admin panel, and they must share rules without sharing every endpoint.
- I structure code by domain so that a feature like loyalty or inventory can grow without touching everything else.
- I use monorepos because shared types and packages across API, web and apps remove a whole class of integration bugs.
- Clear layers make code reviews and AI-assisted work safer: everyone knows where a change belongs.
How it works
01Layers: each part has one job
Think of a restaurant. The waiter takes the order (HTTP layer), the chef decides how to cook it (business layer), the storeroom keeps ingredients (data layer). The waiter never cooks, and the chef never talks to customers. In code: controllers or route handlers parse the request and return a response; services or use cases apply business rules; repositories or ORM models read and write data. Dependencies point inward: the controller knows the service, the service knows the data layer, but the business logic does not know about HTTP. That makes the same logic reusable from an API, a CLI command, a queue job or a test.
- Presentation: controllers, request validation, response formatting.
- Application: services or use cases that orchestrate a task (PlaceOrder, RefundPayment).
- Domain: entities, value objects and rules (Money, OrderStatus transitions).
- Infrastructure: database, queues, payment gateways, email providers.
02Services, use cases and DTOs
A service groups related operations for one domain (OrderService). A use case, or action, is a single operation with a clear name (PlaceOrder). Both take plain input and return plain output. That input is a DTO, a Data Transfer Object: a small, typed object describing exactly the fields an operation accepts. DTOs stop raw request arrays from leaking into business logic and make the contract obvious. Validate at the edges: check shape and types where data enters (HTTP request, webhook, CSV import, message from a queue), then trust the DTO inside. Business rules such as 'a cancelled order cannot be paid' still live in the domain, because they are not about input shape.
// Edge: validate and map to a DTO
export const CreateOrderSchema = z.object({
branchId: z.string().uuid(),
items: z.array(z.object({ productId: z.string().uuid(), qty: z.number().int().min(1) })).min(1),
});
export type CreateOrderDto = z.infer<typeof CreateOrderSchema>;
// Use case: business logic only, no HTTP
export class PlaceOrder {
constructor(private orders: OrderRepository, private pricing: PricingService) {}
async execute(companyId: string, dto: CreateOrderDto) {
const quote = await this.pricing.quote(companyId, dto.items);
return this.orders.create({ companyId, branchId: dto.branchId, ...quote });
}
}
03Folder structure: by domain, not by file type
Small apps can group by type: all controllers in one folder, all models in another. Past twenty or so features this becomes painful, because one change touches five distant folders. Grouping by domain, sometimes called feature folders or modules, keeps everything about orders together. Each module exposes a small public surface (a service, some types) and hides the rest. Shared, cross-cutting code (auth, logging, money helpers) goes into a shared or core folder. In Laravel you can keep the framework's folders and add domain subfolders inside Models and Services; in NestJS modules map to this naturally.
src/
modules/
orders/
orders.controller.ts
orders.service.ts
dto/create-order.dto.ts
orders.module.ts
menu/
loyalty/
shared/
auth/ logging/ money/
config/
env.ts
04Separate APIs for web, mobile and admin
One backend often has three audiences. The public website, the mobile app and the admin panel need different response shapes, different auth (cookie session vs token vs staff login with 2FA), different rate limits, and different release cycles: a mobile app version can live on phones for a year, so its API must stay backward compatible. A good pattern is separate route groups or controllers per channel that call the same services. Customer features that are identical for web and mobile can be defined once and mounted under both prefixes. Admin endpoints live in their own group with stricter permissions and audit logging. Version the mobile API explicitly (v1, v2) so you can evolve it safely.
05Monorepos, config and environment
A monorepo keeps several apps and packages in one repository: api, web, admin, mobile, plus shared packages like types, an API client, UI components or ESLint config. pnpm workspaces link packages locally, and Turborepo (or Nx) runs tasks in dependency order with caching, so only what changed is rebuilt and tested. Configuration should follow the twelve-factor rule: code is the same everywhere, environment differs. Read environment variables once at startup, validate them with a schema and fail fast if something is missing. Never commit .env files; commit a .env.example with placeholder values instead.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
# turbo.json
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
"test": { "dependsOn": ["^build"] },
"lint": {}
}
}
06Keeping architecture healthy over time
Architecture decays one shortcut at a time. Protect it with rules that tools check: lint rules or dependency-cruiser that forbid one module importing another's internals, TypeScript strict mode, and tests at the service layer. Write short architecture notes (an ADR, Architecture Decision Record) when you make a big choice, so the next person knows why. Start as a modular monolith: one deployable app with clean module boundaries. Splitting into services later is easy if boundaries are clean and almost impossible if they are not. Refactor continuously in small steps rather than planning one big rewrite.
Use it when
- Any product expected to live longer than a few months or grow beyond one developer.
- A backend serving several clients: website, mobile app, admin, partners.
- Several apps sharing types, UI or logic, where a monorepo removes duplication.
- Before adding a large new domain (payments, inventory) to an existing codebase.
Skip it when
- Throwaway prototypes and spikes, where speed of learning matters more than structure.
- Adding heavy abstraction layers (repositories over an ORM, interfaces with one implementation) just in case; add them when a second need appears.
Alternatives
- Framework defaults
Plain Laravel or Next.js structure; fine for small apps, add domain folders as you grow.
- Hexagonal / clean architecture
Strict ports and adapters; strong isolation, more boilerplate.
- Microservices
Separate deployables per domain; only worth it with clear boundaries and team scale.
- Polyrepo
One repo per app; simpler permissions, harder to share code and change contracts together.
Best practices
- Keep controllers thin and business logic in services or use cases.
- Validate at every entry point and pass typed DTOs inward.
- Organise by domain module and expose a small public surface per module.
- Separate route groups for web, mobile and admin, sharing the same services.
- Validate environment variables at startup and fail fast.
- Share types and API clients through packages in a monorepo.
- Record big decisions in short ADRs.
Common mistakes
- Business rules inside controllers, views or React components.
- One giant 'utils' or 'helpers' folder that everything depends on.
- Breaking the mobile API because it shares response shapes with the website.
- Over-engineering on day one: five layers and interfaces for a CRUD screen.
- Reading process.env or env() all over the code instead of one typed config.
Deep dive
07Enforcing boundaries with tools, not good intentions
A diagram on a wiki does not stop a tired developer at 6 pm from importing another module's internal repository. A failing CI check does. In TypeScript, dependency-cruiser checks import rules across the whole project, and Nx has an enforce-module-boundaries lint rule for workspaces. In PHP, Deptrac defines layers and allowed dependencies, and Pest architecture tests express rules as tests, such as 'controllers may not use the DB facade'. A good first rule set is small: modules may import only another module's public entry file; domain code may not import HTTP or framework classes; shared code may not import from modules. Add the rules to CI in warning mode, fix existing violations, then switch to error. Every new rule should describe a real problem you have had, not a theoretical purity goal.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'no-cross-module-internals',
comment: 'Import other modules only through their index.ts',
severity: 'error',
from: { path: '^src/modules/([^/]+)/' },
to: {
path: '^src/modules/[^/]+/',
pathNot: ['^src/modules/$1/', '^src/modules/[^/]+/index[.]ts$'],
},
},
{ name: 'shared-stays-generic', severity: 'error', from: { path: '^src/shared/' }, to: { path: '^src/modules/' } },
],
};
08Domain modelling: invariants, aggregates and state machines
An invariant is a rule that must always be true: an order total equals the sum of its lines, a refunded order cannot be paid. An aggregate is the cluster of objects that protects such rules together, like an order with its lines, and it is changed only through its own methods, never by editing a line row directly from elsewhere. Status fields are where most bugs hide, so model them as explicit state machines: a table of allowed transitions and one function that applies them. Then 'can a cancelled order be refunded?' has one answer in one place, and you can test every pair. Combine this with value objects for money (integer minor units plus currency) and quantities, and many classes of bugs, such as float rounding or negative stock, disappear by construction.
type OrderStatus = 'draft' | 'placed' | 'paid' | 'fulfilled' | 'cancelled' | 'refunded';
const allowed: Record<OrderStatus, OrderStatus[]> = {
draft: ['placed', 'cancelled'],
placed: ['paid', 'cancelled'],
paid: ['fulfilled', 'refunded'],
fulfilled: ['refunded'],
cancelled: [],
refunded: [],
};
export function transition(order: Order, to: OrderStatus): Order {
if (!allowed[order.status].includes(to)) {
throw new DomainError('Cannot move order from ' + order.status + ' to ' + to);
}
return { ...order, status: to, version: order.version + 1 };
}
09How modules talk: direct calls, events and read models
Modules must cooperate without knowing each other's insides. Use a direct call to another module's public service when you need an answer now, for example checkout asking pricing for a quote. Use a domain event when something happened and others may react, for example OrderPaid triggering loyalty points, a receipt email and a stock movement; the order module does not need to know who listens. Inside one process, events can be dispatched after commit; once listeners live in another process, write them to an outbox in the same transaction. Avoid two modules writing the same table, because then neither owns the rules. Reports that need data from many modules are a special case: build a read model, a denormalised table updated from events or a nightly job, instead of one giant query that joins every module's tables.
- Command: 'do this' to one owner, may fail, needs an answer.
- Event: 'this happened', zero or more listeners, past tense name.
- Read model: a query-optimised copy, eventually consistent, rebuilt from the source of truth.
10Changing legacy systems: seams, strangler fig and anti-corruption layers
Big-bang rewrites usually fail: the old system keeps changing while the new one chases it. The strangler fig pattern replaces a system piece by piece. Put a routing layer in front, such as a reverse proxy or the router of the new app, move one endpoint or screen at a time, and send the rest to the old code until nothing remains. Before touching risky code, write characterization tests that record what it does today, even if that behaviour looks wrong, so refactoring changes structure and not results. When the new code must talk to the legacy model or an external ERP with strange names and rules, put an anti-corruption layer in between: a translator that maps their concepts into yours, so their mess does not leak into your domain. Feature flags let you switch traffic gradually and roll back instantly.
Real-world scenarios
The 400-line controller nobody wants to touch
Problem
The checkout controller has grown to 400 lines mixing validation, pricing, stock, payment calls and emails. Every change breaks something, and new developers avoid it.
How to solve it
Do not rewrite it in one go. First lock in today's behaviour with feature tests for the main paths: success, out of stock, invalid coupon, payment failure. Then extract step by step: move validation to a FormRequest or schema, move pricing to a PricingService, move the whole flow into a PlaceOrder action that the controller calls, and push emails and stock updates into listeners of an OrderPlaced event dispatched after commit. Run tests after each small step and ship each step separately. The controller ends at around twenty lines, and the same action now serves the mobile and admin channels.
A breaking change while old app versions are still in use
Problem
The order response must change shape for a new feature, but version 1 of the mobile app is still installed on thousands of phones that cannot be forced to update.
How to solve it
Prefer additive changes: add new fields and keep the old ones, because old clients ignore what they do not know. If the shape truly must change, create a v2 route group whose controllers call the same services and use a new Resource, and leave v1 untouched. Send the app version in a header and log usage per version, so you know when v1 traffic is low enough. Announce removal with Deprecation and Sunset response headers and an in-app update prompt, add a minimum-supported-version check, and only delete v1 after the agreed date. Contract tests for each version stop accidental breaks.
Monorepo CI now takes 25 minutes
Problem
The monorepo grew to six apps and ten packages. Every pull request builds and tests everything, so even a typo fix waits 25 minutes for green checks.
How to solve it
Run only what changed. With Turborepo, use turbo run lint test build --affected so only packages touched by the branch, and their dependents, run; enable remote caching so unchanged tasks are restored instead of re-run. Make sure each task declares its outputs and inputs correctly, or caching will be wrong. Split the pipeline into parallel jobs for lint, types, unit tests and E2E, and move slow E2E suites to the merge queue or nightly. Check the dependency graph too: a shared package that every app imports for one helper triggers full rebuilds, so split it into smaller packages.
Cheat sheet
- Run tasks only for packages changed on this branch
turbo run lint test build --affected- Build one app plus everything it depends on
pnpm --filter @app/api... build- Add a workspace package as a dependency
pnpm --filter @app/web add @app/types@workspace:*- Check TypeScript import rules
npx depcruise src --config .dependency-cruiser.cjs- Check PHP layer rules
vendor/bin/deptrac analyse- Pest architecture rule
arch()->expect('App\Domain')->not->toUse('Illuminate\Http');- Validate config once at startup
export const env = EnvSchema.parse(process.env);- Name an architecture decision record
docs/adr/0007-use-outbox-for-domain-events.md
In my real projects
- On a restaurant-chain platform I split Laravel controllers into Shared, Ui, Mobile and Admin layers so web and mobile reuse core logic with separate channels.
- On a pet-supplies platform customer routes are defined once and mounted for both web and mobile namespaces, with services layered over domain models and pricing modelled as value objects.
- A multi-tenant POS SaaS I co-lead is a pnpm and Turborepo monorepo with 6 apps and 10 shared packages.
- I maintain an in-house package monorepo of about 10 reusable packages (account, admin UI, forms, core, e-commerce) used across about 10 client sites.
Questions & answers
Q1How do you structure a backend project from scratch?
I start as a modular monolith organised by domain: orders, catalog, customers, payments, each with its controllers, services, DTOs and data access together. Controllers are thin: validate input, call a service, return a resource. Services hold business rules and do not know about HTTP. Cross-cutting concerns such as auth, logging, config and money handling go into a shared layer. Configuration comes from validated environment variables. I add tests at the service level and feature tests for key endpoints from day one. If a domain later needs independent scaling, clean boundaries make extracting it realistic.
Q2What is a DTO and why use one?
A Data Transfer Object is a simple typed object that describes exactly what data an operation accepts or returns, with no behaviour. Instead of passing a raw request array into a service, you validate the request and map it to, say, CreateOrderDto. Benefits: the service's contract is explicit and type-checked, unknown fields cannot sneak in, the same service can be called from HTTP, a CLI or a queue with the same object, and tests are easy to write. In NestJS DTOs are classes with class-validator decorators; in Laravel a FormRequest or a data class plays that role.
Q3Should web, mobile and admin use the same API?
They should share business logic but usually not the same endpoints. Mobile apps stay installed for months, so their API needs explicit versioning and backward compatibility. Admin endpoints need stricter permissions, audit logs and different shapes, such as bulk operations and filters. The website may use cookie sessions while the app uses tokens. So I create separate route groups or controllers per channel that call the same services. Where a feature is truly identical for web and mobile customers, I define it once and mount it under both prefixes. That keeps one source of truth without coupling release cycles.
Q4When is a monorepo a good idea?
When several apps share code and change together: an API, a web app, an admin panel and a mobile app sharing types, validation schemas, an API client and UI components. A monorepo lets you change an API contract and its consumers in one pull request, run only affected tests, and keep tooling consistent. pnpm workspaces plus Turborepo or Nx handle linking and cached task runs. It is less useful when apps are owned by separate teams with separate release processes, or when access must be restricted per project. Then separate repos with published packages work better.
Q5How do you manage configuration across environments?
Same code everywhere, configuration from environment variables, following twelve-factor principles. I read variables in one place at startup and validate them with a schema such as zod or Laravel config files, failing fast if anything is missing or malformed. The rest of the code reads typed config, never the environment directly. Secrets come from the hosting platform or a secrets manager, never from the repository; the repo has a .env.example with placeholders. Feature flags cover behaviour that should change without a deploy. Each environment, local, staging and production, has its own credentials and databases.
Senior-level questions
S1When would you split a modular monolith into separate services?
Only when a concrete force justifies the cost. Good reasons: a module needs very different scaling, such as an image pipeline or a realtime gateway; a separate team needs to deploy independently; a part needs isolation for security or compliance, such as card data; or a different runtime fits it better. Bad reasons: it feels modern, or the code is messy, because a messy module becomes a messy service with network calls added. Before splitting, the module must already communicate only through a public interface and events, own its tables, and have no cross-module transactions. Then extraction is mostly moving code and replacing in-process calls with HTTP or messages, plus adding retries, timeouts, idempotency and tracing, which is the real ongoing cost.
S2Is clean or hexagonal architecture worth it for a typical product?
The core idea is always worth it: business rules should not depend on HTTP, the ORM or a vendor SDK, and integrations sit behind interfaces you own. The full ceremony often is not. Wrapping Eloquent or Prisma in repositories with one implementation, mapping every entity twice and adding a port for every class triples the code of a CRUD screen and slows the team. I apply it selectively: strict ports and adapters around payments, ERPs, SMS and anything I may replace or must fake in tests, and a pure domain layer for complex rules like pricing or loyalty. Simple admin CRUD stays close to the framework. The test is whether the abstraction has a second real use, such as a test fake or a planned second provider.
S3How do you keep data consistent across modules without shared transactions?
Inside a monolith with one database you can still use a single transaction, but I avoid having one transaction write into several modules' tables, because it couples their schemas. Instead the owning module commits its change and records a domain event in an outbox table in the same transaction. Other modules react from the outbox asynchronously and idempotently. That makes the system eventually consistent, so the UI and processes must accept a short delay, for example loyalty points appearing a second later. For multi-step business processes, such as reserve stock, charge, then confirm, I model a saga with explicit compensation steps rather than pretending it is atomic. The trade-off is more design upfront in exchange for modules that can evolve and later split cleanly.
S4How do you handle reporting across many modules?
Reporting is where boundaries usually break, because someone writes one SQL query joining every module's tables, and now no module can change its schema. I separate reads for reports from the transactional model. Small products can use database views owned by a reporting module, which gives one place to update when schemas change. Larger ones get read models: denormalised tables or a separate analytics database fed by domain events or scheduled extracts, shaped exactly for the dashboards. They are eventually consistent and can be rebuilt from the source of truth, which also makes heavy reports harmless to the production database. The trade-off is pipeline maintenance and slight staleness, which most business reports accept easily.
Glossary
- Bounded context
- A part of the system where a word has one precise meaning, such as 'customer' in billing versus support.
- Invariant
- A business rule that must always hold, for example 'stock never goes below zero'.
- Aggregate
- A group of objects changed together through one entry point so its invariants stay true.
- Value object
- A small immutable type defined by its value, like Money or EmailAddress, that validates itself.
- Read model
- A copy of data shaped for fast queries or reports, updated from the source of truth.
- Anti-corruption layer
- A translation layer that keeps an external or legacy model from leaking into your own domain.
- Strangler fig
- Replacing an old system gradually by routing features one at a time to new code.
- Characterization test
- A test that records what existing code does today, so refactoring can prove nothing changed.
- Cohesion and coupling
- Cohesion: how related things in one module are. Coupling: how much modules depend on each other. Aim high and low.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.