Skip to content

Services ​

Services contain your application's business logic, orchestrating data flow and executing use cases.

Deep Dive: See Services Reference for the full API reference.

The Business Logic Layer ​

Services sit between controllers and repositories. A controller receives an HTTP request and immediately delegates to a service; the service applies business rules, calls one or more repositories, and returns the result.

Primary responsibilities:

  • Encapsulate business rules - validation, state-machine transitions, authorization guards, calculations
  • Coordinate repositories - fetch related data, combine writes, enforce consistency
  • Manage transactions - begin, commit, or roll back across multiple repository calls
  • Compose services - inject and call other services to avoid duplicating logic

Controllers stay thin. Repositories stay dumb. Everything in between is a service.


Creating a Service ​

Extend BaseService, decorate the class with @service(), and call super({ scope: ClassName.name }) in the constructor:

typescript
import { BaseService, inject, service } from '@venizia/ignis';
import { getError, HTTP } from '@venizia/ignis-helpers';
import { UserRepository } from '../repositories';

@service()
export class UserService extends BaseService {
  constructor(
    @inject({ key: 'repositories.UserRepository' })
    private userRepository: UserRepository,
  ) {
    super({ scope: UserService.name });
  }

  async getUser(opts: { id: string }) {
    const user = await this.userRepository.findById({ id: opts.id });

    if (!user) {
      throw getError({
        statusCode: HTTP.ResultCodes.RS_4.NotFound,
        message: 'User not found',
      });
    }

    return user;
  }
}

scope is the tag written into every log line produced by this service. Use ClassName.name - it's the project convention and avoids string drift when renaming the class.


Registering a Service ​

@service() marks the class. artifacts: AutoArtifacts in the application config registers every marked class at boot, before preConfigure(). application.ts names no service.

typescript
// src/application.ts
import AutoArtifacts from '@venizia/ignis-boot/auto';

export const configs: IApplicationConfigs = {
  path: { base: '/api', isStrict: true },
  artifacts: AutoArtifacts,
};

The class binds at the key services.UserService. Any other binding that injects @inject({ key: 'services.UserService' }) resolves an instance from this binding. See Registering artifacts for the full path.

Service bindings are transient by default - each resolution creates a new instance. If your service must be shared (e.g., it holds state), declare the scope on the decorator:

typescript
import { BaseService, BindingScopes, service } from '@venizia/ignis';

@service({ scope: BindingScopes.SINGLETON })
export class UserService extends BaseService {}

@service({ ... }) also takes binding, allowOverride, order and when. See the Services Reference.

Registering by hand ​

this.service(UserService) inside preConfigure() still works, for the rare case a decorator cannot express. It throws when bootChecks.binding.allowManual is false.


Injecting Dependencies ​

Use @inject({ key }) on each constructor parameter. Two equivalent forms:

Plain string keys (concise) ​

typescript
constructor(
  @inject({ key: 'repositories.UserRepository' })
  private userRepository: UserRepository,

  @inject({ key: 'services.NotificationService' })
  private notificationService: NotificationService,
) {
  super({ scope: UserService.name });
}

BindingKeys.build (refactor-safe) ​

typescript
import { BindingKeys, BindingNamespaces } from '@venizia/ignis';

constructor(
  @inject({
    key: BindingKeys.build({
      namespace: BindingNamespaces.REPOSITORY,  // 'repositories'
      key: UserRepository.name,                 // 'UserRepository'
    }),
  })
  private userRepository: UserRepository,
) {
  super({ scope: UserService.name });
}

Both produce the same runtime key (repositories.UserRepository). BindingKeys.build is preferred in larger codebases because a class rename caught by the TypeScript compiler automatically updates the key.


Logging ​

BaseService inherits this.logger from BaseHelper. Scope log lines to the current method:

typescript
async signIn(opts: { username: string }): Promise<string> {
  this.logger.for('signIn').info('SignIn called | username: %s', opts.username);

  const user = await this.userRepository.findByUsername(opts.username);

  if (!user) {
    this.logger.for('signIn').warn('User not found | username: %s', opts.username);
    throw getError({ statusCode: HTTP.ResultCodes.RS_4.Unauthorized, message: 'Invalid credentials' });
  }

  this.logger.for('signIn').info('SignIn successful | userId: %s', user.id);
  return await this.generateToken({ userId: user.id });
}

this.logger.for('signIn') adds a [signIn] tag to each line without creating a new logger object.


Service-to-Service Composition ​

Services can inject other services the same way they inject repositories. This is the primary mechanism for logic reuse.

typescript
@service()
export class RepositoryTestService extends BaseService {
  constructor(
    @inject({
      key: BindingKeys.build({
        namespace: BindingNamespaces.SERVICE,
        key: CrudTestService.name,
      }),
    })
    private readonly crudTestService: CrudTestService,

    @inject({
      key: BindingKeys.build({
        namespace: BindingNamespaces.SERVICE,
        key: TransactionTestService.name,
      }),
    })
    private readonly transactionTestService: TransactionTestService,
  ) {
    super({ scope: RepositoryTestService.name });
  }

  async runAll(): Promise<void> {
    await this.crudTestService.run();
    await this.transactionTestService.run();
  }
}

CrudTestService and TransactionTestService carry @service() too. The DI container resolves the dependency graph automatically - registration order does not matter.


Worked Example: Authentication Service ​

The following is representative of examples/vert/src/services/authentication.service.ts. It shows the complete pattern: multiple injected dependencies, method-scoped logging, error handling, and calling other services.

typescript
import { BaseService, IAuthService, inject, JWKSIssuerTokenService, service, TContext } from '@venizia/ignis';
import { getError, HTTP } from '@venizia/ignis-helpers';
import { compare, genSalt, hash } from 'bcrypt';
import { Env } from 'hono';

@service()
export class AuthenticationService
  extends BaseService
  implements IAuthService<Env, TSignInRequest, TSignInResponse, ...>
{
  constructor(
    // Inject a repository by string key
    @inject({ key: 'repositories.UserRepository' })
    private userRepository: UserRepository,

    // Inject a built-in framework service by string key
    @inject({ key: 'services.JWKSIssuerTokenService' })
    private jwksTokenService: JWKSIssuerTokenService,
  ) {
    super({ scope: AuthenticationService.name });
  }

  async signIn(_context: TContext<Env>, opts: TSignInRequest): Promise<TSignInResponse> {
    this.logger.for('signIn').info('SignIn called | identifier: %j', opts.identifier);

    const user = await this.userRepository.findByUsername(opts.identifier.value);

    if (!user) {
      throw getError({
        statusCode: HTTP.ResultCodes.RS_4.Unauthorized,
        message: 'Invalid credentials',
      });
    }

    const isValid = await compare(opts.credential.value, user.password);
    if (!isValid) {
      throw getError({
        statusCode: HTTP.ResultCodes.RS_4.Unauthorized,
        message: 'Invalid credentials',
      });
    }

    const token = await this.jwksTokenService.generate({
      payload: { userId: user.id, email: user.email },
    });

    this.logger.for('signIn').info('SignIn successful | userId: %s', user.id);
    return { token: { value: token, type: 'Bearer' } };
  }

  async signUp(_context: TContext<Env>, opts: TSignUpRequest): Promise<TSignUpResponse> {
    this.logger.for('signUp').info('SignUp called | username: %s', opts.username);

    const existing = await this.userRepository.findByUsername(opts.username);
    if (existing) {
      throw getError({
        statusCode: HTTP.ResultCodes.RS_4.Conflict,
        message: 'Username already exists',
      });
    }

    const salt = await genSalt();
    const hashedPassword = await hash(opts.credential, salt);

    await this.userRepository.create({ data: { username: opts.username, password: hashedPassword } });

    return { message: 'User registered successfully' };
  }
}

Registration: the @service() above binds the class as services.AuthenticationService, and artifacts: AutoArtifacts registers it.


Transaction Orchestration ​

Use a datasource reference to begin a transaction, then pass the transaction handle through repository options:

typescript
export class CheckoutService extends BaseService {
  constructor(
    @inject({ key: 'datasources.PostgresDataSource' })
    private dataSource: PostgresDataSource,

    @inject({ key: 'repositories.OrderRepository' })
    private orderRepository: OrderRepository,

    @inject({ key: 'repositories.InventoryRepository' })
    private inventoryRepository: InventoryRepository,
  ) {
    super({ scope: CheckoutService.name });
  }

  async placeOrder(opts: { userId: string; items: OrderItem[] }): Promise<Order> {
    const log = this.logger.for('placeOrder');
    const transaction = await this.dataSource.beginTransaction();

    try {
      const { data: order } = await this.orderRepository.create({
        data: { userId: opts.userId },
        options: { transaction },
      });

      for (const item of opts.items) {
        await this.inventoryRepository.updateById({
          id: item.productId,
          data: { stock: item.quantity },
          options: { transaction },
        });
      }

      await transaction.commit();
      log.info('Order placed | orderId: %s', order.id);
      return order;
    } catch (error) {
      await transaction.rollback();
      log.error('Order failed, rolled back | error: %s', error);
      throw error;
    }
  }
}

Always call rollback() in catch - an uncommitted transaction holds a database connection until it is released.


Architecture Diagram ​

Controllers call services. Services call other services and repositories. Repositories call the database. No layer reaches past its immediate neighbor.


Provider vs Service ​

Services contain business logic and are resolved from the DI container (transient scope by default).

Providers implement the Factory pattern - their value(container) method produces a configured value or instance on demand (mail transport, cache driver, middleware). Use a Provider when you need to select between multiple implementations at runtime.

See Providers Reference for the full comparison and examples.


See Also ​