Step 6: Service Layer with Dependency Injection
What You'll Learn
- Use the @Component decorator to create IoC-managed classes
- Use the @Inject decorator to request dependencies
- Design a service layer with proper separation of concerns
- Implement business logic separate from data access
- Handle validation and error handling in services
What You'll Build
A TodosService that uses IoC to inject repository dependencies.
Understanding the Service Layer
The service layer contains your business logic. It sits between controllers (which handle HTTP requests) and repositories (which handle data access). Services are responsible for:
- Business logic: Validation, calculations, transformations
- Orchestration: Coordinating multiple repository calls
- Error handling: Converting data errors to domain errors
- Data transformation: Mapping between DTOs and entities
Project Files Overview
Here's an overview of the service file you'll create. The service injects the repository we'll build in the next step. Click on files in the tree to explore. Files marked with NEW are the ones you'll create.
import { Component, Inject } from "@noego/ioc";
import TodosRepo, { Todo } from "../repo/todos_repo";
@Component()
export default class TodosService {
constructor(
@Inject(TodosRepo) private repo: TodosRepo
) {}
async getTodos(): Promise<Todo[]> {
return this.repo.getAllTodos();
}
async getTodoById(id: number): Promise<Todo | undefined> {
return this.repo.getTodoById(id);
}
async addTodo(title: string, description?: string): Promise<number> {
// Business logic: validate input
if (!title || title.trim().length === 0) {
throw new Error("Title is required");
}
if (title.length > 200) {
throw new Error("Title must be 200 characters or less");
}
// Business logic: transform data
const cleanTitle = title.trim();
const cleanDesc = description?.trim() || null;
// Call repository
const todo = await this.repo.createTodo(cleanTitle, cleanDesc);
return todo.id;
}
async deleteTodo(id: number): Promise<void> {
// Business logic: verify exists before delete
const todo = await this.repo.getTodoById(id);
if (!todo) {
throw new Error(`Todo with id ${id} not found`);
}
await this.repo.deleteTodo(id);
}
}The @Component Decorator
To register a class with IoC, decorate it with @Component(). This registers
the class with the IoC container:
import { Component } from "@noego/ioc";
@Component()
export default class LoggerService {
log(message: string) {
console.log(`[LOG]`, message);
}
}The @Inject Decorator
Use the @Inject decorator to request a dependency in your constructor.
The IoC container automatically provides the instance:
import { Component, Inject } from "@noego/ioc";
import TodosRepo from "../repo/todos_repo";
@Component()
export default class TodosService {
constructor(
@Inject(TodosRepo) private repo: TodosRepo
) {}
}Building the TodosService
Let's create a complete service that uses the repository we'll build next:
import { Component, Inject } from "@noego/ioc";
import TodosRepo, { Todo } from "../repo/todos_repo";
@Component()
export default class TodosService {
constructor(
@Inject(TodosRepo) private repo: TodosRepo
) {}
async getTodos(): Promise<Todo[]> {
return this.repo.getAllTodos();
}
async getTodoById(id: number): Promise<Todo | undefined> {
return this.repo.getTodoById(id);
}
async addTodo(title: string, description?: string): Promise<number> {
// Business logic: validate input
if (!title || title.trim().length === 0) {
throw new Error("Title is required");
}
if (title.length > 200) {
throw new Error("Title must be 200 characters or less");
}
// Business logic: transform data
const cleanTitle = title.trim();
const cleanDesc = description?.trim() || null;
// Call repository
const todo = await this.repo.createTodo(cleanTitle, cleanDesc);
return todo.id;
}
async deleteTodo(id: number): Promise<void> {
// Business logic: verify exists before delete
const todo = await this.repo.getTodoById(id);
if (!todo) {
throw new Error(`Todo with id ${id} not found`);
}
await this.repo.deleteTodo(id);
}
}Service vs Repository Responsibilities
Understanding the boundary between services and repositories is important:
| Repository | Service |
|---|---|
| Raw database queries | Business logic and validation |
| Single table operations | Multi-repository orchestration |
| Returns database entities | May transform to DTOs |
| No validation | Input validation |
| SQL-level concerns | Domain-level concerns |
Multiple Dependencies
Services can inject multiple repositories or other services:
@Component()
export default class UserTodosService {
constructor(
@Inject(TodosRepo) private todosRepo: TodosRepo,
@Inject(UsersRepo) private usersRepo: UsersRepo,
@Inject(LoggerService) private logger: LoggerService
) {}
async getTodosForUser(userId: number) {
this.logger.log(`Fetching todos for user ${userId}`);
const user = await this.usersRepo.getUserById(userId);
if (!user) {
throw new Error("User not found");
}
return this.todosRepo.getTodosByUserId(userId);
}
}Singleton Behavior
By default, all @Component() decorated classes are singletons. The same
instance is shared across all injections. This is ideal for services that maintain
no state or need shared state.
Service Best Practices
- Single Responsibility: Each service should focus on one domain area
- Keep services stateless: Avoid storing request-specific data in service properties
- Validate at boundaries: Validate input before passing to repositories
- Throw meaningful errors: Use descriptive error messages for debugging
- Use async/await: All database operations are async, so services should be too
What's Next?
Now that we have a service layer for business logic, let's create the repository layer to persist our data. We'll set up the database and use SQLStack for type-safe queries.