Service — The Container

Resources & Dependencies

Database connections, APIs, external systems needed

Resources are the dependencies a service needs to do its work: database connection pools, HTTP clients, message producers, or any external system integration.

Defining resources on the service builder makes them available — fully typed — in every command and subscription of that service. This is how PURISTA achieves dependency injection without frameworks, containers, or reflection.

Define a resource

Resources are declared in the service builder as TypeScript types only:

import type { DbType } from 'my-db'

const serviceBuilder = new ServiceBuilder(serviceOneInfo)
  .setConfigSchema(serviceOneSchema)
  .defineResource<'db', DbType>()
  .defineResource<'emailClient', EmailClient>()

This adds db with type DbType and emailClient with type EmailClient to the context of every command and subscription.

Definitions vs instances

defineResource only declares the type. The actual instance is provided when the service instance is created.

Provide instances at startup

When creating a service instance, pass the resources object:

import { MyDb } from 'my-db'

const myService = await serviceBuilder.getInstance(eventBridge, {
  logger,
  resources: {
    db: new MyDb(process.env.DATABASE_URL),
    emailClient: new EmailClient({ apiKey: process.env.EMAIL_API_KEY }),
  },
})

Use resources in handlers

Access resources through the handler context:

commandBuilder.setCommandFunction(async function ({ resources }) {
  const user = await resources.db.query('SELECT * FROM users WHERE id = $1', [userId])
  await resources.emailClient.send({ to: user.email, subject: 'Welcome!' })
  return { user }
})

Resource lifecycle

Resources follow a simple lifecycle:

  1. DefinitiondefineResource<'db', DbType>() declares the interface in the builder.
  2. Instantiation — The real object is created outside the service and passed to getInstance().
  3. Access — Commands and subscriptions receive the instance through their context.
  4. Cleanup — If a resource has a close() or disconnect() method, call it in a shutdown hook or process exit handler.

Why resources reduce lock-in

Because resources are defined as interfaces, the actual implementation can be swapped without touching business logic:

  • Move from PostgreSQL to MySQL? Update the db resource adapter. Commands stay identical.
  • Switch email providers from SendGrid to AWS SES? Swap the emailClient resource. No command changes.
  • Migrate from one cloud to another? Only resource adapters change.

This is the core of PURISTA’s anti-lock-in architecture: business logic depends on interfaces, not products.

Common resource patterns

ResourceTypical typeInjected as
Database poolPgPool / PrismaClientConnection pool or ORM client
HTTP clientAxiosInstance / HttpClientPre-configured client with base URL and auth
Cache clientRedisClientConnection to Redis or Valkey
File storageS3Client / MinioClientObject storage client
Email clientNodemailerTransport / customPre-configured mailer with provider settings

Testing with mock resources

Since resources are TypeScript types, mocking is straightforward:

import { createSandbox } from 'sinon'

const sandbox = createSandbox()
const dbMock = {
  query: sandbox.stub().resolves([{ id: '123', name: 'Alice' }]),
}
const emailMock = {
  send: sandbox.stub().resolves({ messageId: 'abc' }),
}

const myService = await serviceBuilder.getInstance(eventBridge, {
  logger,
  resources: { db: dbMock, emailClient: emailMock },
})

During tests, you replace real infrastructure with stubs. Business logic tests run in milliseconds with no external dependencies.

Prefer resources over custom classes

Resources offer better decoupling, a higher level of abstraction, and improved scalability compared to custom service classes. Use a custom class only when you need lifecycle hooks like start() and destroy() on the service itself.

Related

Read Next
Command

from Core Building Blocks