Separating responsibilities
A maintainable backend application separates code into layers, each with a single responsibility. The request crosses them from outside in:
HTTP → Controller → Service → Repository → Database
Controller (HTTP)
It is the (req, res) handler. Its only job is to translate HTTP: read
req.body/req.params, call the service and respond with the appropriate status
and JSON. It contains no business rules or SQL.
async function create(req, res) {
const user = await service.register(req.body);
res.status(201).json(user);
}
Service (business logic)
It contains the rules: domain validations, "do not allow duplicate emails",
computing totals, orchestrating several repositories. It does not know about
req/res or SQL; it asks the repository for data.
async function register(data) {
const exists = await repo.findByEmail(data.email);
if (exists) throw new Error("Duplicate email");
return repo.create(data);
}
Repository (data)
The one you saw before: only data access.
Dependency injection
Notice that the service uses repo and the controller uses service. Instead of
creating those dependencies inside (const repo = new SqlRepo()), they are
received from outside. That is dependency injection:
function createService(repo) { // receives the repo as a parameter
return {
async register(data) {
if (await repo.findByEmail(data.email)) {
throw new Error("Duplicate email");
}
return repo.create(data);
},
};
}
Advantages: in production you inject the real repository; in tests you inject a mock or an in-memory repo, without touching the service. Each layer is tested in isolation, and the whole stays decoupled and easy to change.