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.');
}