Core Patterns
A safe, incremental migration from technical-first to domain-first folder structure. The key principle: never do a big-bang rewrite. Move one feature at a time while keeping the application running.
Step 1: Audit the Existing Structure
Map every file in your technical-layer folders to its owning business capability before moving anything.
# Audit worksheet: identify which domain each file belongs to
controllers/orderController.ts → orders/
controllers/userController.ts → users/
controllers/paymentController.ts → payments/
models/order.ts → orders/
models/user.ts → users/
services/orderService.ts → orders/
services/cartService.ts → cart/
repositories/orderRepository.ts → orders/
utils/dateFormatter.ts → shared/dates/
utils/passwordHasher.ts → users/ (domain-specific, not truly shared)
utils/emailSender.ts → shared/email/ (infrastructure — truly shared)
Goal: every file gets a domain home before the first move.
Step 2: Create the New Structure Alongside the Old
Add domain folders without deleting anything. Both structures coexist temporarily.
src/
├── controllers/ # old — still works, do not delete yet
│ ├── orderController.ts
│ └── userController.ts
├── models/ # old
│ └── order.ts
├── services/ # old
│ └── orderService.ts
├── orders/ # new — empty, being populated
│ └── .gitkeep
└── users/ # new — empty, being populated
└── .gitkeep
This approach keeps the application deployable at every commit.
Step 3: Migrate One Feature at a Time
Pick the smallest, most self-contained feature first. Use the strangler fig pattern: new code goes in the domain folder, old files become thin re-exports pointing to the new location.
// Step A: create the domain file in its new home
// src/orders/order.service.ts
export class OrderService {
async findById(id: string): Promise<Order> { /* impl */ }
async confirm(id: string): Promise<void> { /* impl */ }
}
// Step B: turn the old file into a re-export (keeps imports elsewhere working)
// src/services/orderService.ts ← OLD FILE, now a shim
export { OrderService } from '../orders/order.service';
// Step C: update all imports across the codebase to point to the new path
// Run: grep -r "from '../services/orderService'" src/ to find consumers
// Then update each import one PR at a time
// Step D: delete the shim after all consumers are updated
Step 4: Handle Shared Utilities During Migration
Classify each utility before moving it. Three categories:
Category 1 — Domain-specific (move INTO the feature):
utils/passwordHasher.ts → users/password.ts
utils/orderTotals.ts → orders/order.entity.ts (method on the aggregate)
Category 2 — Truly cross-cutting infrastructure (move to shared/):
utils/logger.ts → shared/logger/index.ts
utils/httpClient.ts → shared/http/index.ts
utils/config.ts → shared/config/index.ts
Category 3 — Generic helpers (keep in shared/, rename clearly):
utils/dateFormatter.ts → shared/dates/format.ts
utils/crypto.ts → shared/crypto/hash.ts
Rule: if a utility knows about a domain concept (Order, User, Payment), it is domain logic, not a utility.
Step 5: Resolve Cross-Cutting Concerns
Cross-cutting concerns (logging, auth, DB connection, config) need explicit homes before features can be truly self-contained.
src/
└── shared/ # infrastructure layer — never domain logic here
├── database/
│ ├── connection.ts # DB client singleton
│ └── base-repository.ts # abstract CRUD helper
├── logger/
│ └── index.ts # structured logger instance
├── config/
│ └── index.ts # env var loading and validation
├── errors/
│ ├── base.error.ts # AppError base class
│ ├── not-found.error.ts
│ └── validation.error.ts
└── middleware/ # HTTP middleware shared across features
├── auth.middleware.ts
└── rate-limit.middleware.ts
Never put business logic in shared/. If code in shared/ starts knowing about Orders or Users, it belongs in that feature.
Step 6: Git Strategy for Large-Scale Reorganization
Big-bang folder moves cause merge conflicts and make PRs impossible to review. Use this branching strategy instead.
# Branch strategy: one feature per PR
main
└── feat/domain-orders # PR 1: migrate orders feature
└── feat/domain-users # PR 2: migrate users feature (stacked on main)
└── feat/domain-payments # PR 3: migrate payments (stacked on main)
# Each PR contains:
# 1. New domain folder with migrated files
# 2. Shims in old locations (backward-compatible re-exports)
# 3. Updated imports in files that consume the migrated feature
# 4. Tests confirming the feature still works
# Cleanup PR (after all features migrated):
feat/remove-technical-layers
# Deletes controllers/, services/, models/, repositories/ folders
# Removes all shims
# One final PR — easy to review because all it does is delete dead code
Avoid git mv for entire directories when files also change content — reviewers cannot see both the move and the edit in the same diff. Instead: copy file to new location with content changes, then delete the old file in a separate commit.
Before/After: Monolith to Domain-First
Complete transformation of a Node.js/Express e-commerce backend.
### Before: technical layers at root
src/
├── controllers/
│ ├── catalogController.ts # GET /products, GET /products/:id
│ ├── cartController.ts # POST /cart/items, DELETE /cart/items/:id
│ ├── orderController.ts # POST /orders, GET /orders/:id
│ └── userController.ts # POST /users, GET /users/me
├── models/
│ ├── product.ts
│ ├── cart.ts
│ ├── cartItem.ts
│ ├── order.ts
│ └── user.ts
├── services/
│ ├── catalogService.ts
│ ├── cartService.ts
│ ├── orderService.ts
│ ├── emailService.ts # used by orders AND users
│ └── userService.ts
├── repositories/
│ ├── productRepository.ts
│ ├── cartRepository.ts
│ ├── orderRepository.ts
│ └── userRepository.ts
├── middleware/
│ ├── auth.ts
│ └── validate.ts
├── routes/
│ └── index.ts
└── utils/
├── dateFormat.ts
├── passwordHash.ts
└── orderCalculator.ts
### After: domain-first structure
src/
├── catalog/
│ ├── product.entity.ts # was: models/product.ts
│ ├── product.repository.ts # was: repositories/productRepository.ts
│ ├── catalog.service.ts # was: services/catalogService.ts
│ ├── catalog.routes.ts # was: controllers/catalogController.ts
│ └── catalog.test.ts
├── cart/
│ ├── cart.aggregate.ts # was: models/cart.ts + cartItem.ts
│ ├── cart.repository.ts # was: repositories/cartRepository.ts
│ ├── cart.service.ts # was: services/cartService.ts
│ ├── cart.routes.ts # was: controllers/cartController.ts
│ └── cart.test.ts
├── orders/
│ ├── order.aggregate.ts # was: models/order.ts
│ ├── order.repository.ts # was: repositories/orderRepository.ts
│ ├── order.service.ts # was: services/orderService.ts
│ │ # + orderCalculator moved INTO this service
│ ├── order.routes.ts # was: controllers/orderController.ts
│ └── order.test.ts
├── users/
│ ├── user.entity.ts # was: models/user.ts
│ │ # + passwordHash moved INTO this entity
│ ├── user.repository.ts # was: repositories/userRepository.ts
│ ├── user.service.ts # was: services/userService.ts
│ ├── user.routes.ts # was: controllers/userController.ts
│ └── user.test.ts
└── shared/
├── database/
│ └── connection.ts
├── email/
│ └── email.service.ts # was: services/emailService.ts (truly shared)
├── errors/
│ └── base.error.ts
├── middleware/
│ ├── auth.ts # was: middleware/auth.ts
│ └── validate.ts # was: middleware/validate.ts
└── dates/
└── format.ts # was: utils/dateFormat.ts
Result: a new developer opens the project and sees catalog/, cart/, orders/, users/ — immediately understanding the business without reading a single line of code.