Oyam Notes

Patterns: Service Class

In this series I will describe some of the design patterns that I commonly use in my work. They are suited to backend projects in Python and TypeScript because that's what I mostly work on.

Besides sharing, the purpose of these posts is to have a recipe I can show to an LLM agent so that it uses the building blocks I'm most familiar with. Seeing familiar patterns in the agent's output makes code review a lot easier.

Intro

A service class is a class that encapsulates the business logic of a particular feature. It's preferably immutable: it doesn't keep state in its own instances, though it may use a DB or a cache.

In terms of clean architecture, services belong to the application (use cases) layer.

Clean architecture diagram

Construction

A service class accepts configuration and dependencies as constructor parameters and stores them as instance properties.

export class ExampleService {
  #param1: number;
  #param2: string;
  #db: DB;
  #cache: Cache;

  constructor({
    param1,
    param2,
    db,
    cache,
  }: {
    param1: number;
    param2: string;
    db: DB;
    cache: Cache;
  }) {
    this.#param1 = param1;
    this.#param2 = param2;
    this.#db = db;
    this.#cache = cache;
  }
}

// Or, if there are a lot of configs and dependencies, the split 
// may be made explicit:

export interface ExampleServiceConfig {
  param1: number;
  param2: string;
}

export interface ExampleServiceDeps {
  db: DB;
  cache: Cache;
}

export class ExampleService {
  #config: ExampleServiceConfig;
  #deps: ExampleServiceDeps;

  constructor({
    config,
    deps,
  }: {
    config: ExampleServiceConfig;
    deps: ExampleServiceDeps;
  }) {
    this.#config = config;
    this.#deps = deps;
  }
}

Instances of the service are supposed to be constructed by the bootstrapping code. The bootstrapper then passes the service object to other code as a dependency or, if it's the main service, passes control to it.

// main.ts or index.ts somewhere

const exampleService = new ExampleService({
  param1: config.exampleService.param1,
  param2: config.exampleService.param2,
  db,
  cache,
});

// Say, it's the main service:
exampleService.run();

If appropriate for the project, the service may also provide a static method or a function that creates an instance from the project's configuration fetched from some global source:

export class ExampleService {
  // ...
  static async getInstance(): Promise<ExampleService> {
    const config = await fetchGlobalConfigFromSomewhere();
    const db = await getDbConnection();
    const cache = await getCacheConnection();
    return new ExampleService({
      param1: config.exampleService.param1,
      param2: config.exampleService.param2,
      db,
      cache,
    });
  }
}

// Or, to decouple config and dependency fetching from the service,
// instantiation may be extracted into a separate function, possibly
// in a separate file:

export async function getExampleServiceInstance(): Promise<ExampleService> {
  const config = await fetchGlobalConfigFromSomewhere();
  const db = await getDbConnection();
  const cache = await getCacheConnection();
  return new ExampleService({
    param1: config.exampleService.param1,
    param2: config.exampleService.param2,
    db,
    cache,
  });
}

API

The service, like any good abstraction, exposes only the public API that supports the abstraction. Nothing extra is exposed to consumers, including the configs and the dependencies, unless they are part of the API.

The service's interface should suggest how it's meant to be used. Just by looking at the exported symbols of the service's file and the public members of its class, the reader should be able to infer how the service is used.

For example, a service may imply a workflow through its API:

export class SmsService {
  sendSms(recipient: PhoneNumber, text: string): Promise<Sms> {}
  pollSmsStatus(sms: Sms): Promise<void> {}
  cancelSmsSending(sms: Sms): Promise<void> {}

  // All other members are private.
  #callApi(endpoint: string, payload: unknown): Promise<ApiResponse> {}
}

// The service may also use private functions that don't need the service instance.
function mapApiResponse(r: ApiResponse): Sms {}

Another workflow example:

export class OrderService {
  validate(orderContents: OrderContents): ValidatedOrderContents {}
  submit(orderContents: ValidatedOrderContents): Promise<Order> {}
  accruePayment(order: Order, payment: Payment): Promise<void> {}
  refundPayment(order: Order): Promise<void> {}
  markSentForDelivery(order: Order): Promise<void> {}
  markDelivered(order: Order): Promise<void> {}
}

In another example, the service lists mostly independent operations that can be performed through it:

export class SupportService {
  submitFinancialInquiry(data: FinancialInquiryData): Promise<Ticket> {}
  submitBug(data: BugData): Promise<Ticket> {}
  submitAccountDeletion(data: AccountDeletionData): Promise<Ticket> {}
  // And so on...
}

It's preferable to require arguments to service methods to be passed by name rather than positionally if there are more than a couple of them and their positions say nothing about their role.

class OrderService {
  // ๐Ÿ›‘ BAD
  async submit(
    clientData: ClientData,
    deliveryData: DeliveryData,
    orderItems: OrderItem[],
  ): Promise<Order> {}

  // โœ… GOOD
  async submit({
    clientData,
    deliveryData,
    orderItems,
  }: {
    clientData: ClientData;
    deliveryData: DeliveryData;
    orderItems: OrderItem[];
  }): Promise<Order> {}
}

The same in Python:

class OrderService:
    # ๐Ÿ›‘ BAD
    def submit(
        self,
        client_data: ClientData,
        delivery_data: DeliveryData,
        order_items: list[OrderItem],
    ) -> Order:
        ...

    # โœ… GOOD: Note the asterisk.
    def submit(
        self,
        *,
        client_data: ClientData,
        delivery_data: DeliveryData,
        order_items: list[OrderItem],
    ) -> Order:
        ...

Data Types

A service must declare its own data structures if it accepts or returns complex data types. This ensures loose coupling with other code.

export const OrderStatus = {
  New: "New",
  Paid: "Paid",
  InDelivery: "InDelivery",
  Complete: "Complete",
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

export interface OrderContents {
  clientName: string;
  clientPhone?: PhoneNumber;
  clientAddress: string;
  items: OrderItem[];
}

export interface OrderItem {
  productSku: string;
  quantity: number;
  price: bigint;
}

export class OrderService {
  // ...
}

This may introduce some duplication and make consumers write glue code to translate between similar types from different domains. But the duplication is worth the loose coupling it buys.

// Somewhere a consumer has to translate between the types of loosely
// coupled domains.
function mapOrderToStorageOrder(order: Order): StorageOrder {
  return {
    clientName: order.clientName,
    clientPhone: order.clientPhone,
    clientAddress: order.deliveryAddress,
  };
}

A service may depend on "foreign" data types if the project has an established domain layer that is safe to depend upon. For example, the model layer in Django:

# The Order model is imported, but OrderContents is tied to the service's
# API, so it's declared locally.
from project.orders.models import Order

@dataclass(kw_only=True, frozen=True, slots=True)
class OrderItem:
    product_sku: str
    quantity: int
    price: Decimal

@dataclass(kw_only=True, frozen=True, slots=True)
class OrderContents:
    client_name: str
    client_phone: PhoneNumber | None
    client_address: str
    items: list[OrderItem]

@dataclass(kw_only=True, frozen=True, slots=True)
class OrderService:
    def submit(self, contents: OrderContents) -> Order:
        ...

Errors

If the service's methods throw, the service must declare its own error type. Ideally, the service should only throw errors of that type and its subtypes. This makes the service more predictable: consumers only have to watch out for one error type.

// The base class for all errors of OrderService.
export class OrderServiceError extends Error {
  override name = "OrderServiceError";
}

export class OrderValidationError extends OrderServiceError {
  override name = "OrderValidationError";

  // Extra properties for a specific error type.
  messages: string[];

  constructor(messages: string[]) {
    super(messages.join(" "));
    this.messages = messages;
  }
}

// Could not submit the order to the backend API.
export class OrderBackendError extends OrderServiceError {
  override name = "OrderBackendError";
}

If the service calls a foreign function that may throw, it has to catch the exception and either handle it or wrap it in its own error type. The service should avoid letting exceptions of foreign types propagate through it.

class OrderService:
    def submit(self, order_contents: ValidatedOrderContents) -> Order:
        # ...
        try:
            http_resp = self.http_client.post("/order/", {...})
        except HttpError as e:
            raise OrderBackendError(f"Failed to submit the order: {e}") from e
        # ...

This rule can be relaxed when it's more idiomatic to throw a standard exception type for the given stack. For example, in Python it is idiomatic to raise TypeError when the programmer makes a type error:

class OrderService:
    def validate(self, order_contents: OrderContents) -> ValidatedOrderContents:
        for item in order_contents.items:
            if isinstance(item.quantity, float):
                raise TypeError("OrderService cannot operate on float quantities!")

        # ...

If the service has to report errors to the end user, it may introduce a subclass of errors whose messages are safe to display in the UI directly. This makes it clear to the consumer when they can forward the service's error to the user and when they have to mask it with a generic message to avoid leaking technical details.

// Messages of OrderServiceError are NOT safe to be displayed to the user.
export class OrderServiceError extends Error {
  override name = "OrderServiceError";
}

// Messages of OrderUserError are safe to be displayed to the user.
export class OrderUserError extends OrderServiceError {
  override name = "OrderUserError";
}

// Safe to be displayed by inheritance.
export class OrderValidationError extends OrderUserError {
  override name = "OrderValidationError";
}

// Not safe.
export class OrderBackendError extends OrderServiceError {
  override name = "OrderBackendError";
}

export class OrderService {
  // ...

  async validate(
    orderContents: OrderContents,
  ): Promise<ValidatedOrderContents> {
    for (const item of orderContents.items) {
      let product: Product;
      try {
        product = await this.#productRepo.fetchProduct(item.productSku);
      } catch (err) {
        if (err instanceof ProductNotFoundError) {
          throw new OrderValidationError("This product is not available.");
        }

        throw new OrderBackendError("Could not fetch product.", {cause: err});
      }

      // ...
    }

    // ...
  }
}

A service should only introduce a new error subtype when the consumer has to react differently to errors of that particular kind. For example, the consumer might want to distinguish transient API errors from persistent ones, retrying on the former and giving up on the latter. But if there is no need for a rich error hierarchy, the service can make do with as little as a single error type (the base class).

Dependencies

Ideally, a service shouldn't depend on other services. It should only depend on lower-level units, like adapters. If some business logic spans several services, it's wiser to introduce a higher-level unit that manages that logic, for example a workflow or an orchestrator.

This rule keeps the dependency graph between project units clear: all dependencies go from higher levels to lower levels, with few dependencies within the same level. Uncontrolled dependencies between services may result in circular dependencies and spaghetti code.

File Structure

A basic service lives in a single file together with its private functions, data types and errors. If the service grows too large, the file may be split.

The typical file structure of ExampleService, originally in services/example.ts, after splitting looks like this:

services/
โ””โ”€โ”€ example/
    โ”œโ”€โ”€ service.ts  <-- The service implementation.
    โ”œโ”€โ”€ types.ts  <-- Data types.
    โ””โ”€โ”€ errors.ts  <-- Error types.

The service may introduce other files to store cohesive sets of private functions. For example, a service that translates between the data types of its dependencies may introduce a mapping.ts or serialization.ts module.

If the service outgrows the flat file structure (specifically, if service.ts is too large by itself), it may be split by methods. Each method is converted into a function and put into its own file. The service class then simply forwards method calls to these functions. To give them access to the configs and dependencies, the service declares a "context" data type that holds the configs and dependency references.

For example, here's the decomposition of an OrderService:

services/
โ””โ”€โ”€ order/
    โ”œโ”€โ”€ service.ts
    โ”œโ”€โ”€ types.ts
    โ”œโ”€โ”€ errors.ts
    โ”œโ”€โ”€ context.ts  <-- Context type
    โ””โ”€โ”€ methods/  <-- Method functions
        โ”œโ”€โ”€ validate.ts
        โ”œโ”€โ”€ submit.ts
        โ”œโ”€โ”€ mark-sent-for-delivery.ts
        โ””โ”€โ”€ mark-complete.ts

The context.ts file looks like this:

export interface OrderContext {
  // Copies the service's properties verbatim:
  param1: number;
  param2: string;
  db: DB;
  cache: Cache;
}

The service class then forwards the calls:

import validate from "./methods/validate.ts";
import submit from "./methods/submit.ts";
import markSentForDelivery from "./methods/mark-sent-for-delivery.ts";
import markComplete from "./methods/mark-complete.ts";

export class OrderService {
  // ...

  validate(orderContents: OrderContents): Promise<ValidatedOrderContents> {
    return validate(this.#getContext(), orderContents);
  }

  submit(orderContents: ValidatedOrderContents): Promise<Order> {
    return submit(this.#getContext(), orderContents);
  }

  markSentForDelivery(order: Order): Promise<void> {
    return markSentForDelivery(this.#getContext(), order);
  }

  markComplete(order: Order): Promise<void> {
    return markComplete(this.#getContext(), order);
  }

  #getContext(): OrderContext {
    return {
      param1: this.#param1,
      param2: this.#param2,
      db: this.#db,
      cache: this.#cache,
    };
  }
}

The method implementations then work as before, using the context instead of this or self. Private helpers specific to a single method stay inside that method's file and don't bloat the entire service.

If the methods need access to some common functionality, it may be implemented on the context class to be easily shared:

export class OrderContext {
  maxOrdersPerMin: number;
  db: DB;
  cache: Cache;

  constructor({
    maxOrdersPerMin,
    db,
    cache,
  }: {
    maxOrdersPerMin: number;
    db: DB;
    cache: Cache;
  }) {
    this.maxOrdersPerMin = maxOrdersPerMin;
    this.db = db;
    this.cache = cache;
  }

  async verifyRateLimit(orderContents: OrderContents): Promise<void> {
    // Verifies that the client did not submit more than `maxOrdersPerMin` orders.
    // Throws otherwise.
  }
}

Another way to extract common functionality from the methods is to put it into an internal module. This keeps the context class slim.

services/
โ””โ”€โ”€ order/
    โ”œโ”€โ”€ service.ts
    โ”œโ”€โ”€ types.ts
    โ”œโ”€โ”€ errors.ts
    โ”œโ”€โ”€ context.ts
    โ”œโ”€โ”€ methods/
    โ”‚   โ”œโ”€โ”€ validate.ts
    โ”‚   โ””โ”€โ”€ submit.ts
    โ””โ”€โ”€ internal/
        โ””โ”€โ”€ rate-limit.ts  <-- Here

The methods may then import the internal modules:

// methods/validate.ts
import { verifyRateLimit } from "../internal/rate-limit.ts";

export default async function validate(
  ctx: OrderContext,
  orderContents: OrderContents,
): Promise<ValidatedOrderContents> {
  await verifyRateLimit(ctx, orderContents);
  // ...
}

Ideally, the method files shouldn't import each other, for the same reason that services shouldn't depend on each other: to keep the dependency graph clean and avoid circular dependencies. If one method needs some functionality of another, it's better to extract that functionality into an internal module or the context class.