Step 6 of 12 50% complete

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.

my-noego-app
server/service/todos_service.ts NEW
server
repo
todos_repo
service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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:

Basic @Component usage
1
2
3
4
5
6
7
8
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:

Injecting dependencies
1
2
3
4
5
6
7
8
9
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:

server/service/todos_service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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:

RepositoryService
Raw database queriesBusiness logic and validation
Single table operationsMulti-repository orchestration
Returns database entitiesMay transform to DTOs
No validationInput validation
SQL-level concernsDomain-level concerns

Multiple Dependencies

Services can inject multiple repositories or other services:

Multiple dependencies
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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.

Troubleshooting

NoEgo

© 2025 NoEgo. All rights reserved.