Davstack Service is beautifly simple and flexible library for building backend services with TypeScript.
The API is heavily inspired by the tRPC procedure builder, providing an extremely intuitive and familiar DX.
The key difference is that Davstack Service is a service builder, not a procedure API builder.
-
Full Reusability: Services can be called directly from anywhere in your backend, including within other services, without the overhead of unnecessary API calls.
-
Flexible & Portable: Services are lightweight wrappers around typescript functions, so they can be integrated into any backend eg Next.js server components / actions and support broad range of content types (eg files, streams, etc).
-
Seamless Integration with tRPC: Davstack Service is built to complement tRPC. You can easily turn your services into tRPC procedures / routers with 0 boilerplate.
npm install zod @davstack/service
Visit the DavStack Service Docs for more information and examples, such as this trpc usage example.
- The service definition replaces tRPC procedures, but the syntax is very similar.
- Once the service is integrated into tRPC routers, the API is the same as any other tRPC router.
// api/services/invoice.ts
import { authedService, publicService } from '@/lib/service';
// Service composed from range of other services:
export const mailAiGeneratedInvoice = authedService
.input(z.object({ to: z.string(), projectId: z.string() }))
.query(async ({ ctx, input }) => {
// each service is called directly, no API calls
await checkSufficientCredits(ctx, { amount: 10 });
// The inputs / outputs are type safe and validated by Zod
const pdf = await generatePdf(ctx, { html: project.invoiceHtml });
// Services are just functions - so no limitaitons of content types (eg files, streams, etc)
await sendEmail(ctx, {
to: input.to,
subject: 'Invoice',
body: 'Please find attached your invoice',
attachments: [{ filename: 'invoice.pdf', content: pdf }],
});
await deductCredits(ctx, { amount: 10 });
return 'Invoice sent';
});
// Each service is a small, reusable function
// Easy to test, easy to understand, easy to maintain
export const generatePdf = authedService
.input(z.object({ html: z.string() }))
.query(async ({ ctx, input }) => {
// complex business logic here
return pdf;
});
export const sendEmail = authedService
.input(z.object({ to: z.string(), subject: z.string(), body: z.string() }))
.query(async ({ ctx, input }) => {
// complex business logic here
return 'Email sent';
});
export const checkSufficientCredits = authedService
.input(z.object({ amount: z.number() }))
.query(async ({ ctx, input }) => {
// complex business logic here
return 'Sufficient funds';
});
// ... etc
Integrate your services with tRPC with 0 boilerplate. Works just like any other tRPC router.
// api/router.ts
import * as invoiceServices from '@/api/services/invoice';
import { createTRPCRouter } from '@/lib/trpc';
import {
createTrpcProcedureFromService,
createTrpcRouterFromServices,
} from '@davstack/service';
export const appRouter = createTRPCRouter({
invoice: createTrpcRouterFromServices(invoiceServices),
});
Define your services in a separate file, and export them for use in your backend.
// lib/service.ts
import { service } from '@davstack/service';
import { db } from '@/lib/db';
// Define the context types for your services
export type PublicServiceCtx = {
user: { id: string; role: string } | undefined;
db: typeof db;
};
export type AuthedServiceCtx = Required<PublicServiceCtx>;
// export your services
export const publicService = service<PublicServiceCtx>();
export const authedService = service<AuthedServiceCtx>().use(
async ({ ctx, next }) => {
// Only allows authenticate users to access this service
if (!ctx.user) {
throw new Error('Unauthorized');
}
return next(ctx);
}
);
export function createServiceCtx() {
const user = auth();
return { user, db };
}
Import the public / authed service builders from the service file, and define your services. You can use the query
or mutation
methods to define the service function.
// api/services/some-service.ts
import { publicService, authedService } from '@/lib/service';
export const getSomePublicData = publicService.query(async ({ ctx }) => {
return 'Public data';
});
export const getSomeUserData = authedService.query(async ({ ctx }) => {
// will throw an error if ctx.user is undefined
return 'Protected data';
});
Specify the input and output schemas for your service for validation and type safety, and use the ctx/input arguments to access the service context and input data.
// api/services/task-services.ts
import { service } from '@davstack/service';
import { z } from 'zod';
const getTasks = service()
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.tasks.findMany({ where: { projectId: input.projectId } });
});
Unlike tRPC procedures, services can be called directly from anywhere in your backend, including within other services.
const ctx = createServiceCtx(); // or get ctx from parent service
const tasks = await getTasks(ctx, { projectId: '...' });
This allows you to build complex service logic by composing multiple services together.
const getProjectDetails = service()
.input(z.object({ projectId: z.string() }))
.output(
z.object({
id: z.string(),
name: z.string(),
tasks: getTasks.outputSchema,
})
)
.query(async ({ ctx, input }) => {
const project = await getProject(ctx, { projectId: input.projectId });
const tasks = await getTasks(ctx, { projectId: input.projectId });
return { ...project, tasks };
});
Seamlessly integrate with tRPC to create type-safe API endpoints.
import { initTRPC } from '@trpc/server';
import { createTrpcRouterFromServices } from '@davstack/service';
import * as taskServices from './services/tasks';
import * as projectServices from './services/projects';
import { sendFeedback } from './services/send-feedback';
const t = initTRPC();
const appRouter = t.router({
tasks: createTrpcRouterFromServices(taskServices),
projects: createTrpcRouterFromServices(projectServices),
// or create a single procedure from a service
sendFeedback: createTrpcProcedureFromService(sendFeedback),
});
NOTE: it is recommended to use the * as yourServicesName
syntax. Otherwise, ctrl+click on the tRPC client handler will navigate you to the app router file, instead of the specific service definition.
The fluent API is heavily inspired by the tRPC procedure builder, providing an extremely intuitive and familiar DX.
Services still have access to ctx from middleware, use input/output schemas, and outputs can also be inferred from the query/mutation function, just like tRPC.
The key difference is that Davstack Service is a service builder, not a procedure API builder.
This brings several benefits by decoupling your service logic (eg database read/write operations), from the transport layer (eg REST or tRPC APIs).
Davstack Store has been heavily inspired by tRPC, a fantastic library for building type-safe APIs. A big shout-out to the tRPC team for their amazing work.
Nick-Lucas, a tRPC contributor, inspired the creation of Davstack Service with his github comment. He suggested "making controllers minimal" and "to separate your business logic from the API logic", which is exactly what Davstack Service aims to do.
Contributions are welcome! Please read our contributing guide for details on our code of conduct and the submission process.
This project is licensed under the MIT License. See the LICENSE file for details.