Microservices example

Try it
Start your own copy of this example:
npm create @setu-ts my-app -- --example microservices
cd my-app && npm install
npm start
The commands further down run inside a clone of the repository.

Service A resolves and calls service B through ServiceDiscoveryPlugin’s static provider. With REDIS_URL set, service B owns the Redis Streams respond handler and service A issues the request, proving brokered request/reply crosses the service boundary. Without Redis, the smoke check still proves discovery, reports the brokered half as skipped, and exits 77.

pnpm --dir apps/microservices run start
pnpm --dir apps/microservices run smoke
REDIS_URL=redis://127.0.0.1:6379 pnpm --dir apps/microservices run smoke

Read the example deep dive for the concept and source walkthrough.

Read, in order

src/greeting.contract.ts

/** The brokered topic service B answers greetings on. */
export const GREETING_TOPIC = 'service-b.greet';

/** The name service discovery resolves service B under. */
export const SERVICE_B = 'service-b';

export interface GreetingRequest {
  readonly name: string;
}

export interface GreetingReply {
  readonly greeting: string;
}

/** Builds the greeting both service B's route and its responder answer with. */
export function greetingFor(name: string): GreetingReply {
  return { greeting: `Hello, ${name}!` };
}

src/service-a/greeting.client.ts

import { CAPABILITIES } from '@setu-ts/common';
import type { IMessageBroker, IServiceDiscovery, IServiceRegistry } from '@setu-ts/common';
import { GREETING_TOPIC, SERVICE_B } from '../greeting.contract.ts';
import type { GreetingReply, GreetingRequest } from '../greeting.contract.ts';

/** Resolves service B through discovery and asks its HTTP route for a greeting. */
export async function greetOverHttp(services: IServiceRegistry): Promise<string> {
  const discovery = services.get<IServiceDiscovery>(CAPABILITIES.SERVICE_DISCOVERY);
  const url = await discovery.resolveUrl(SERVICE_B, '/hello');
  if (url === null) throw new Error(`Service discovery did not resolve ${SERVICE_B}.`);

  const response = await fetch(url);
  if (!response.ok) throw new Error(`${SERVICE_B} returned ${response.status} at ${url}.`);
  const { greeting } = (await response.json()) as Partial<GreetingReply>;
  if (greeting === undefined) throw new Error(`${SERVICE_B} returned no greeting.`);
  return greeting;
}

/** Asks service B for a greeting through the shared broker. */
export async function greetOverBroker(services: IServiceRegistry): Promise<string> {
  const broker = services.get<IMessageBroker>(CAPABILITIES.MESSAGING);
  const reply = await broker.request<GreetingRequest, GreetingReply>(GREETING_TOPIC, {
    name: 'service-a',
  });
  return reply.greeting;
}

src/service-a/service-a.config.ts

import { createApplication } from '@setu-ts/kernel';
import type { IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ServiceDiscoveryPlugin } from '@setu-ts/service-discovery-plugin';
import { SERVICE_B } from '../greeting.contract.ts';
import { optionalMessaging } from '../messaging.ts';

/** Builds service A, which finds service B through static discovery. */
export function createServiceA(serviceBPort: number, redisUrl?: string): IKernelApplication {
  return createApplication({
    plugins: [
      RuntimePlugin(),
      ...optionalMessaging(redisUrl),
      ServiceDiscoveryPlugin({
        provider: 'static',
        services: { [SERVICE_B]: [{ host: '127.0.0.1', port: serviceBPort }] },
      }),
    ],
  });
}

src/service-b/greeting.responder.ts

import { CAPABILITIES } from '@setu-ts/common';
import type { IMessageBroker, IServiceRegistry, ISubscription } from '@setu-ts/common';
import { GREETING_TOPIC, greetingFor } from '../greeting.contract.ts';
import type { GreetingReply, GreetingRequest } from '../greeting.contract.ts';

/** Answers brokered greeting requests until the returned subscription is unsubscribed. */
export async function respondToGreetings(services: IServiceRegistry): Promise<ISubscription> {
  const broker = services.get<IMessageBroker>(CAPABILITIES.MESSAGING);
  return await broker.respond<GreetingRequest, GreetingReply>(GREETING_TOPIC, (request) =>
    greetingFor(request.name),
  );
}

src/service-b/service-b.config.ts

import { createApplication } from '@setu-ts/kernel';
import type { IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { greetingFor } from '../greeting.contract.ts';
import { optionalMessaging } from '../messaging.ts';

/** Builds service B: an HTTP greeting, plus the shared broker when Redis is configured. */
export function createServiceB(redisUrl?: string): IKernelApplication {
  const app = createApplication({ plugins: [RuntimePlugin(), ...optionalMessaging(redisUrl)] });
  app.router.get('/hello', (ctx) => ctx.response.json(greetingFor('service-a')));
  return app;
}

smoke.ts

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { IKernelApplication } from '@setu-ts/kernel';
import { freePort, skipAfterTests } from '../smoke-helpers.ts';
import { greetOverBroker, greetOverHttp } from './src/service-a/greeting.client.ts';
import { createServiceA } from './src/service-a/service-a.config.ts';
import { respondToGreetings } from './src/service-b/greeting.responder.ts';
import { createServiceB } from './src/service-b/service-b.config.ts';

const redisUrl = process.env['REDIS_URL'];

describe('microservices example', () => {
  let serviceA: IKernelApplication;
  let serviceB: IKernelApplication;

  before(async () => {
    const serviceBPort = await freePort();
    serviceB = createServiceB(redisUrl);
    serviceA = createServiceA(serviceBPort, redisUrl);
    await serviceB.start({ port: serviceBPort });
    await serviceA.start();
  });

  after(async () => {
    await Promise.all([serviceA.stop(), serviceB.stop()]);
  });

  it('service A discovers service B and calls it over HTTP', async () => {
    assert.equal(await greetOverHttp(serviceA.services), 'Hello, service-a!');
  });

  it(
    'service B answers service A through the Redis broker',
    { skip: redisUrl === undefined },
    async () => {
      const responder = await respondToGreetings(serviceB.services);
      try {
        assert.equal(await greetOverBroker(serviceA.services), 'Hello, service-a!');
      } finally {
        await responder.unsubscribe();
      }
    },
  );
});

if (redisUrl === undefined) {
  skipAfterTests('set REDIS_URL to run the cross-service Redis request/reply check.');
}