sdxc
SearchCtrlK

[ The sdxc command ]

Make a service

Write a business rule that spans several models with sdxc make service, returning a Result its callers branch on.

Last updated 2026-10-19

A service carries out a rule that spans more than one model, or that belongs to none: charge a customer and record the invoice, extend a trial and email its owner. sdxc make service writes one in app/services/, a function that returns a Result so every caller branches on what went wrong:

sdxc make service billing/charge
Wrote app/services/billing/charge.ts
Wrote app/services/billing/charge.test.ts

A rule about one model belongs on that model, as a method. A service is for what sits between them, and it is how a controller, a job and a command share one rule.

Flags

FlagDefaultWhat it does
--test, --no-testmake.testswrite the test beside it, or skip it
--dry-runprint the files and change nothing

What it writes

A function named after the file's last segment in camelCase:

app/services/billing/charge.ts
import type { Result } from "@sdxc/result";

import { success } from "@sdxc/result";

/** Carries out Charge; a failure says why nothing changed. */
export function charge(): Result<void, Error> {
	// TODO: carry out the rule across models
	return success(undefined);
}

Nothing is declared or mapped: a caller imports the function it needs. Use the same folder name the feature uses in the other layers, app/jobs/billing/ and app/services/billing/, so one search finds the whole feature.

The test

app/services/billing/charge.test.ts
import { isSuccess } from "@sdxc/result";
import { describe, expect, test } from "vitest";

import { charge } from "~/app/services/billing/charge";

describe("charge", () => {
	test("succeeds", () => {
		expect(isSuccess(charge())).toBe(true);
	});
});
bun run test app/services/billing/charge.test.ts

Fill it in

Take what the rule reads as parameters, the models included, so a test passes its own:

app/services/billing/charge.ts
export async function charge(
	models: AppModels,
	customerId: string,
	cents: number,
): Promise<Result<Invoice, Error>> {
	let customer = await models.customer.find(customerId);
	if (customer === null) return failure(new Error("No such customer"));
	let invoice = await models.invoice.create({
		customer_id: customerId,
		amount_cents: cents,
	});
	return success(invoice);
}

AppModels and Invoice stand for your app's own types. A caller passes ctx.models from a controller, a job or a command, and answers a failure its own way: a page, a retry, an exit code. Result everywhere covers success, failure and the helpers that combine them, and @sdxc/result the whole API.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.