Skip to content

Services and Business Logic

The Service layer is the operational core of a Karin application. While Controllers handle the "Interface" (HTTP), Services handle the "Domain" (Rules, Data, Logic).

Services are responsible for:

  • Data Persistence: Interacting with databases via ORMs (Prisma, Drizzle, Mongoose).
  • External Integration: Communicating with third-party APIs (Stripe, AWS, SendGrid).
  • Computation: Executing complex algorithms or data transformations.
  • Validation: Enforcing business invariants (e.g., "A user cannot be created without an email").

By isolating this logic into reusable classes, Karin ensures your application remains testable, maintainable, and decoupled from the transport layer. A Service method can be invoked by a REST Controller, a WebSocket Gateway, or a CLI command with equal ease.

Concept: The Chef

In our model, the Service is the Chef.

  • Receives Orders: Accepts validated input from the Waiter (Controller).
  • Knowledgeable: Knows the recipes (Business Rules) and techniques.
  • Tools: Has access to the pantry (Database) and appliances (External APIs).
  • Output: Produces the finished dish (Result data).
  • Agnostic: The Chef doesn't care if the order came from Table 4 or a Takeout App; they just cook.

Defining a Service

A Service is a standard TypeScript class decorated with @injectable() or @Service(). This metadata allows the Dependency Injection (DI) container to manage its lifecycle and resolve its dependencies.

typescript
import { injectable } from "@project-karin/core";

@injectable()
export class UsersService {
  private readonly users = [];

  findAll() {
    return this.users;
  }

  create(user: any) {
    this.users.push(user);
    return user;
  }
}

Registration

How the service is registered depends on your runtime mode:

  1. Auto-Discovery (Bun/Monolith): The framework scans your files. If it sees @injectable() or @Service(), it automatically registers the class in the global container.
  2. Explicit Registration (Serverless): You must import the class and pass it to the providers array in KarinFactory.serverless().

Dependency Injection (DI)

Karin utilizes a powerful Inversion of Control (IoC) container (powered implicitly by tsyringe mechanisms). This allows classes to declare their dependencies rather than instantiating them, promoting loose coupling.

Constructor Injection

The standard way to consume a dependency is via the constructor. Karin utilizes TypeScript's emitDecoratorMetadata to automatically infer the types.

typescript
@injectable()
export class OrdersService {
  // Karin sees 'UsersService' in the type signature.
  // It resolves the UsersService singleton and injects it here.
  constructor(private readonly usersService: UsersService) {}

  async placeOrder(userId: string, item: string) {
    const user = this.usersService.findById(userId); // Use the dependency
    if (!user) throw new Error("User not found");
    // ... logic
  }
}

Token-Based Injection (@Inject)

Sometimes, the dependency is not a class. It might be:

  • An Interface (which disappears at runtime in JS).
  • A Primitive value (string, number).
  • A Symbol.

In these cases, you must use a specific Token.

1. Registering a Value (Manual Mode):

typescript
const app = KarinFactory.serverless(adapter, {
  providers: [{ provide: "API_KEY", useValue: "sk_live_12345" }],
});

2. Injecting the Value: Use the @Inject() decorator to explicitly tell the container which token to resolve.

typescript
import { Inject, injectable } from "@project-karin/core";

@injectable()
export class StripeService {
  constructor(@Inject("API_KEY") private readonly apiKey: string) {}

  charge() {
    console.log(`Charging via key: ${this.apiKey}`);
  }
}

Dependency Scopes

The "Scope" determines the lifecycle of the service instance—how often it is created and destroyed.

1. Singleton (Default)

Behavior: The framework creates a single instance of the service at application startup. This instance is cached and shared across every request and every other service that depends on it.

  • Ideal for: Stateless services, Database connections, Configuration managers, Caches.
  • Warning: Because the instance is shared, never store request-specific state (like this.currentUserId) in a Singleton. It will lead to race conditions where User A sees User B's data.

2. Transient

Behavior: A new instance is created every time the dependency is requested. If OrderService and PaymentService both inject LogService, they will receive two different instances of LogService.

  • Ideal for: Request-scoped context tracking, isolated lightweight helpers.
  • Performance: Higher memory and GC overhead. Use sparingly in high-throughput applications.

To define a transient service, utilize the underlying DI container's registration options (or register a factory that returns a new instance).


Advanced Providers (Manual Mode)

In explicit registration mode, the providers array accepts more than just classes. It accepts Provider Objects, allowing for complex wiring.

useClass

The standard behavior. Maps a Token to a Class.

typescript
{ provide: AuthService, useClass: AuthService }
// Short syntax:
AuthService

useValue

Maps a Token to a specific constant value (object, string, number). Useful for mocking configuration or existing libraries.

typescript
const mockDb = { find: () => [] };

{ provide: 'DATABASE_CONNECTION', useValue: mockDb }

useFactory

The most powerful provider. It accepts a factory function that can execute logic to create the dependency. It can even accept other dependencies as arguments.

typescript
{
  provide: 'ASYNC_CONFIG',
  useFactory: async (configService: ConfigService) => {
    const secret = await configService.fetchRemoteSecret();
    return { secret };
  },
  inject: [ConfigService] // Dependencies to pass to the factory function
}

Lifecycle Hooks

Services often need to perform setup or cleanup logic (e.g., connecting to a database, closing a socket). Karin provides lifecycle interfaces.

onModuleInit()

Executed once, when the application starts (after the DI container is built but before the server listens).

typescript
@injectable()
export class DatabaseService implements OnModuleInit {
  async onModuleInit() {
    console.log("Connecting to DB...");
    await this.dbClient.connect();
  }
}

onModuleDestroy()

Executed when the application receives a termination signal (SIGINT, SIGTERM). Used to gracefully close connections to prevent data loss or zombie processes.

typescript
@injectable()
export class DatabaseService implements OnModuleDestroy {
  async onModuleDestroy() {
    console.log("Closing DB connection...");
    await this.dbClient.disconnect();
  }
}

Testing Services

The architecture of Karin is specifically designed to make testing Services easy. Because Services are just classes with constructor dependencies, KarinFactory is not required to test them.

You can perform Unit Tests by manually instantiating the service and passing Mock Dependencies.

Example: Testing UserService with a Mock DB

typescript
// users.service.spec.ts
import { describe, expect, test } from "bun:test";
import { UsersService } from "./users.service";

// 1. Create a Mock for the dependency
// We don't need a real database connection.
const mockRepository = {
  find: () => [{ id: 1, name: "Test User" }],
  save: (user) => ({ id: 2, ...user }),
};

describe("UsersService", () => {
  // 2. Instantiate the service manually
  // Dependency Injection is just passing arguments to a constructor!
  const service = new UsersService(mockRepository as any);

  test("findAll returns users", async () => {
    const result = await service.findAll();
    expect(result).toHaveLength(1);
    expect(result[0].name).toBe("Test User");
  });
});

This approach allows for extremely fast (millisecond-level) test suites that are reliable and deterministic.

Released under the MIT License.