Handlers, Controllers & Services
Layered architecture. Why your service layer should never know about HTTP.
The Layered Architecture
Production backends separate concerns into layers:
Handler/Controller Layer → Service/Business Logic Layer → Repository/Data Layer
Handler: Deals with HTTP. Reads request, calls service, sends response.
Service: Contains business rules. No HTTP knowledge. Pure logic.
Repository: Wraps database queries. No business logic. Just CRUD.
This separation means:
• Services are testable without HTTP or a database
• Handlers are thin and uniform
• Repositories can swap databases without touching business logic
Handlers (Controllers)
A handler's only job: translate HTTP ↔ domain.
// What a handler does:
async function createUserHandler(req, res) {
// 1. Parse & validate input (or let middleware do it)
const dto = CreateUserDto.parse(req.body);
// 2. Call the service
const user = await userService.createUser(dto);
// 3. Serialize and respond
res.status(201).json(toUserResponse(user));
}
What a handler should NOT do:
• Database queries
• Business logic ("if user is admin, give 20% discount")
• Sending emails directly
• Complex data transformations
Keep handlers stupid. They're glue code.
Services (Business Logic Layer)
The service layer is the heart of your application. This is where decisions live.
// Service example
class UserService {
constructor(private userRepo, private emailService, private cache) {}
async createUser(dto) {
// Business rules:
const existing = await this.userRepo.findByEmail(dto.email);
if (existing) throw new ConflictError("Email already in use");
const hashedPassword = await bcrypt.hash(dto.password, 12);
const user = await this.userRepo.create({ ...dto, hashedPassword });
await this.emailService.sendWelcomeEmail(user.email);
await this.cache.invalidate("users:list");
return user;
}
}
Services should be:
• Framework-agnostic (no req/res objects)
• Testable with mocked dependencies
• Composed through dependency injection
Repository Pattern
A repository abstracts the database. Instead of raw SQL everywhere, you have methods:
interface UserRepository {
findById(id: string): Promise<User | null>
findByEmail(email: string): Promise<User | null>
create(data: CreateUserInput): Promise<User>
update(id: string, data: Partial<User>): Promise<User>
delete(id: string): Promise<void>
findMany(filters: UserFilters): Promise<User[]>
}
Benefits:
• Swap databases without touching business logic
• Easy to mock in tests
• Consistent query interface
• Enforce data access patterns (no raw queries scattered in services)
The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.