Skip to Content
ControllerService

Service

A service module is part of the Controller-Service-Repository pattern, used to separate business logic from request handlers. It allows you to keep your controllers clean and focused on handling HTTP requests, while the service class contains the actual business logic and data manipulation.

Similar to controllers, services are implemented as static classes with static methods, but they don’t require the use of decorators or any special design. This means that the static class syntax for services is merely a convention, and you can use any applicable way to define business logic: normal classes that need to be instantiated, separate functions, or plain objects.

The best feature of service methods is that their typing can be inferred from controller methods that call them, without causing “implicit any” TypeScript errors. Thanks to the legendary Anders Hejlsberg  for the solution to #58616 —without this fix to the TypeScript language, Vovk.ts wouldn’t be possible.

Let’s say you have the following controller class:

src/modules/user/UserController.ts
import { z } from 'zod'; import { prefix, post, operation } from 'vovk'; import { withZod } from 'vovk-zod'; import UserService from './UserService'; @prefix('users') export default class UserController { @operation({ summary: 'Update user', description: 'Update user by ID with Zod validation', }) @post('{id}') static updateUser = withZod({ body: z .object({ name: z.string().describe('User full name'), age: z.number().min(0).max(120).describe('User age'), email: z.email().describe('User email'), }) .describe('User object'), params: z.object({ id: z.uuid().describe('User ID'), }), query: z.object({ notify: z.enum(['email', 'push', 'none']).describe('Notification type'), }), output: z .object({ success: z.boolean().describe('Success status'), id: z.uuid().describe('User ID'), }) .describe('Response object'), async handle(req) { const body = await req.vovk.body(); const query = req.vovk.query(); const params = req.vovk.params(); return UserService.updateUser(body, query, params); }, }); }

As you can see, the handle method returns the result of the UserService.updateUser method. The UserService.updateUser method, in turn, infers types from the controller method, making the validation models (in this case, Zod schemas) the only source of truth for the input and output types, without the need to define them as separate variables.

src/modules/user/UserService.ts
import type { VovkBody, VovkOutput, VovkParams, VovkQuery } from 'vovk'; import type UserController from './UserController'; export default class UserService { static updateUser( body: VovkBody<typeof UserController.updateUser>, query: VovkQuery<typeof UserController.updateUser>, params: VovkParams<typeof UserController.updateUser> ) { // perform DB operations or other business logic here console.log(body, query, params); return { success: true, id: params.id } satisfies VovkOutput<typeof UserController.updateUser>; } }

In other words, service methods can infer types from controller methods, and controller methods can call service methods without causing self-referencing issues.

Last updated on