Service — The Container

The Service Builder

Define metadata, attach handlers, and create instances

The service builder is the factory that defines everything a service needs: its identity, configuration schema, resources, and all attached handlers.

Think of it as the blueprint. The builder collects declarations. The service file wires them together. The instance connects them to runtime infrastructure.

Service definition

Every service starts with stable metadata. This metadata never changes for the lifetime of the service version:

import { ServiceBuilder, ServiceInfoType } from '@purista/core'

export const userServiceInfo = {
  serviceName: 'UserService',
  serviceVersion: '1',
  serviceDescription: 'Handles user registration, authentication, and profiles',
} as const satisfies ServiceInfoType

export const userServiceV1ServiceBuilder = new ServiceBuilder(userServiceInfo)

The serviceName becomes the domain boundary. The serviceVersion is a contract: version 1 promises a specific set of commands and subscriptions. When you break that contract, you increment the version.

The three naming levels

PURISTA uses a consistent three-level naming convention for every service:

LevelName patternWhat it is
BuilderuserServiceV1ServiceBuilderThe ServiceBuilder instance with info, config schema, and resource declarations. Imported by command and subscription builders.
ServiceuserServiceV1ServiceThe builder after all command, subscription, and stream definitions have been attached via .addCommandDefinition(). This is what you export from the service directory.
InstanceuserServiceThe live runtime object returned by await userServiceV1Service.getInstance(eventBridge, opts). One instance per process or container.

This naming makes the lifecycle unambiguous: you build once, you wire once, and you instantiate at runtime.

Typical file structure

A well-organized service follows a predictable layout. This makes navigation automatic for developers and AI agents:

  • src source root
    • services all services
      • userService domain: users
        • userServiceV1ServiceBuilder.ts metadata + config + resources
        • userServiceV1Service.ts wiring: attach handlers
        • userServiceConfig.ts Zod schema + types
        • commands sync operations
          • signUp register a user
            • signUpCommandBuilder.ts
            • schema.ts
            • types.ts
            • signUpCommandBuilder.test.ts
          • getUser fetch a user
            • getUserCommandBuilder.ts
            • schema.ts
            • types.ts
            • getUserCommandBuilder.test.ts
        • subscriptions event reactions
          • sendWelcomeEmail on user created
            • sendWelcomeEmailSubscriptionBuilder.ts
            • schema.ts
            • types.ts
            • sendWelcomeEmailSubscriptionBuilder.test.ts
        • streams real-time flows
          • userSearch search stream
            • userSearchStreamBuilder.ts
            • schema.ts
            • types.ts
            • userSearchStreamBuilder.test.ts

Attach handlers

Keep definition lists in the service file and spread them into the builder. This pattern preserves type inference end-to-end:

import { signUpCommandBuilder } from './commands/signUp/signUpCommandBuilder.js'
import { getUserCommandBuilder } from './commands/getUser/getUserCommandBuilder.js'
import { sendWelcomeEmailSubscriptionBuilder } from './subscriptions/sendWelcomeEmail/sendWelcomeEmailSubscriptionBuilder.js'
import { userSearchStreamBuilder } from './streams/userSearch/userSearchStreamBuilder.js'
import { userServiceV1ServiceBuilder } from './userServiceV1ServiceBuilder'

const commandDefinitions = [
  signUpCommandBuilder.getDefinition(),
  getUserCommandBuilder.getDefinition(),
] as Parameters<typeof userServiceV1ServiceBuilder['addCommandDefinition']>[0][]

const subscriptionDefinitions = [
  sendWelcomeEmailSubscriptionBuilder.getDefinition(),
] as Parameters<typeof userServiceV1ServiceBuilder['addSubscriptionDefinition']>[0][]

const streamDefinitions = [
  userSearchStreamBuilder.getDefinition(),
] as Parameters<typeof userServiceV1ServiceBuilder['addStreamDefinition']>[0][]

export const userServiceV1Service = userServiceV1ServiceBuilder
  .addCommandDefinition(...commandDefinitions)
  .addSubscriptionDefinition(...subscriptionDefinitions)
  .addStreamDefinition(...streamDefinitions)
Preserve typed declarations

Keep the constant names commandDefinitions, subscriptionDefinitions, and streamDefinitions. Use the typed declaration Parameters<typeof builder[‘add…Definition’]>[0][] — do not replace with untyped arrays.

Why split builder and service files

Keep the basic service builder (...ServiceBuilder.ts) separate from service wiring (...Service.ts) to avoid cyclic dependencies. Command, subscription, and stream builders import from the service builder to inherit typed context. The service file imports the builders to attach them. Splitting the files breaks the cycle:

ServiceBuilder.ts  →  CommandBuilder.ts
       ↑                    ↓
   Service.ts  ←——  command.getDefinition()

Add config and resources

Most real services need configuration and resources. Chain them on the builder before attaching handlers:

export const userServiceV1ServiceBuilder = new ServiceBuilder(userServiceInfo)
  .setConfigSchema(userServiceV1ConfigSchema)
  .defineResource<'db', DatabaseClient>()
  .defineResource<'emailClient', EmailClient>()

These declarations become available — fully typed — in every command and subscription context.

Create and start an instance

const userService = await userServiceV1Service.getInstance(eventBridge, {
  logger,
  resources: {
    db: new DatabaseClient(process.env.DATABASE_URL),
    emailClient: new EmailClient(),
  },
})
await userService.start()

Always call start() so definitions are registered at the event bridge and startup hooks can run.

Builder method reference

MethodPurpose
setConfigSchema(schema)Attach a Zod schema for service-level configuration
defineResource<Name, Type>()Declare a typed resource dependency
addCommandDefinition(...defs)Attach command definitions
addSubscriptionDefinition(...defs)Attach subscription definitions
addStreamDefinition(...defs)Attach stream definitions
setCustomClass(Class)Use a custom service class (rarely needed)

Related

Read Next
Command

from Core Building Blocks