Service layer
The service layer concentrates business logic. Put a default-exported service class in src/services/; the framework discovers and instantiates it, then attaches it to app.services for route handlers to use. This page starts with an example that needs no database, then covers naming, dependencies, and lifecycle.
Design concept
- Route handler is only responsible for extracting parameters from the request, calling the service, and returning the response
- Service layer owns business use cases; avoid direct access to
req/resso routes and Jobs can share it - Data layer provided by plugins (such as database ORM), accessed through the
appobject
This layering enables:
- Business logic can be reused between different routes
- The service layer can be unit tested independently (not relying on HTTP)
- Switching the underlying Adapter does not affect the business code
These are recommended responsibilities, not framework-enforced restrictions on every cross-layer call. A service may still use app.throw() for HTTP errors; non-HTTP consumers must decide how to handle those exceptions. See Architecture and Validation and contracts.
Basic writing method
Service class
Each service file must default-export a class or another constructor that can be called with new and accepts app. A class is recommended. This runnable example returns mock data, verifies service injection and validation, and does not persist users.
Prerequisite: a TypeScript application from Quick Start that runs with npm run dev. Merge this configuration into the starter application, then add the service and route files. Merge or replace existing files with the same names; do not declare duplicate default exports. The requests below use port 3000. If local configuration, the provider, or the CLI overrides it, check the actual listening port in Configuration.
Use in a route
Start npm run dev, then make these requests from another terminal. For Bash and other POSIX shells:
On Windows PowerShell, use Invoke-RestMethod so older PowerShell versions do not alter JSON quotes passed to a native command:
Expect, in order: 200 (data.items is empty, page is 1, limit is 20), 200 (data.id is the string 42), 201 (data.id is a generated UUID), and 422 (field validation fails). This example allows unauthenticated requests. To require authentication, register the complete authentication middleware and Guard as described in Security; copying an unregistered auth name is insufficient.
After checking development requests, stop the development server, run npm run build (including --typecheck) and npm start as configured in Quick Start, then repeat the requests. The list is still empty after creating a user because this example has no persistence.
Later sections show separate patterns and extensions. Replace or merge their UserService methods as needed; do not paste multiple default exports into one file.
File naming and mapping
service-loader automatically mounts the service instance to the corresponding property of app.services according to the file path.
Mapping rules
Conversion Rules:
- The file path is relative to the
services/directory, with the extension removed. - The file name is automatically converted from
kebab-casetocamelCase - Subdirectories are mapped to nested objects
index is an ordinary service key, unlike route index collapsing: services/payment/index.ts maps to app.services.payment.index. Every path segment participates in name conversion. Duplicate keys after conversion, or a key used both for a service and a directory namespace, cause a load-time conflict.
Nested service example
Service Hooks
Vext installs lightweight wrappers for instance methods loaded into app.services. When the service hook is not registered, the call will go directly to the original method; after registering the hook, you can observe the before and after calls and errors:
Only ordinary methods on the prototype chain are wrapped. Constructors, instance arrow-function fields, getters, and setters are excluded. A service:beforeCall listener that throws prevents the original method from running; afterCall and error observers are dispatched safely and do not replace the method result.
service:beforeCall, service:afterCall and service:error are all synchronous hooks. Do not return Promise in these handlers; if asynchronous reporting is required, it is recommended to put it in a queue or use log transmission that does not block the main call.
Inter-service calls
Services can call each other. Access this.app.services on demand inside a method rather than capturing another service in the constructor:
::::warning avoid circular dependencies
service-loader and vext doctor share a bounded static dependency graph. A detected cycle between ServiceA and ServiceB fails startup. The first constructor parameter of the default export is the injected application, regardless of its name. Direct instance assignments, TypeScript parameter properties, and traceable local aliases are supported, including static string access to namespaced services.
Comments, strings, and unrelated local objects do not create dependencies. Dynamic service names, reassignment, inheritance, and untraceable origins report incomplete analysis. The runtime precheck warns and Doctor retains that incomplete status. A static graph does not prove that every runtime path is cycle-free, and analysis does not execute business code to fill its gaps.
✅ Recommended — Defer access until the method runs:
Deferred access addresses initialization order only. If A and B still call each other's methods, they may form a static cycle or runtime recursion. Change the dependency direction instead.
❌ Wrong Practice — Direct reference in the constructor:
::::
Use the capabilities provided by the plug-in
Capabilities injected by plugins via app.extend() are accessed in the service via this.app:
::::tip type tip
Use declare module to extend the VextApp interface to get full type hints:
The extension gives this.app.redis IDE completion. Its declaration must match the client API that the plugin actually injects. This example describes an application contract; Redis SDKs do not all share this set signature or return type. A type declaration does not establish a runtime connection.
::::
Use app.throw() to throw an error
Services may throw HTTP errors through this.app.throw(). When an HTTP request calls the service and lets the exception propagate, the framework catches it and produces a unified error response. A Job or another non-HTTP caller receives an exception, not an HTTP response:
- When you need to actively return
404,409,401and other clear HTTP semantics, usethis.app.throw(...) - When field-level validation details need to be returned,
VextValidationErroris thrown - When an unexpected exception occurs, you can directly
throw new Error("..."), and the framework will uniformly convert it to 500
If throw new Error("...") is directly inside the service, the framework will also catch it; this path represents an unknown runtime exception, rather than an actively designed HTTP error response. By default, the client will receive a safe 500 Internal Server Error. In the development environment, the stack can be additionally exposed through response.hideInternalErrors = false to facilitate troubleshooting.
Validate non-HTTP input in the service
Route inputs are declared through RouteOptions.validate, and the handler reads validated data with req.valid(). For non-HTTP inputs processed directly by a service, such as scheduled tasks, message queues, external callbacks, or other service calls, reuse the application's validation engine through this.app.getValidator().
getValidator() returns the synchronous schema-dsl validator by default. A plugin may replace it with an adapter implementing VextValidator; a raw Zod or Yup instance cannot be assumed to have the same interface. This example compiles and stores a validation function in the constructor. Replace the engine before loading services: a later replacement does not recompile an already stored function.
::::tip
Use app.getValidator() for inputs that should follow the framework's shared validation contract. A separate schema library bypasses app.setValidator(); if you choose one deliberately, document its different syntax, error, and conversion semantics. Schema validation does not replace business checks such as inventory, eligibility, or uniqueness.
::::
Use app.logger to record logs
Use this.app.logger for structured service logs. An HTTP call with an established request context can carry requestId through AsyncLocalStorage. Startup code, Jobs, and calls outside that context cannot assume an HTTP requestId exists:
Loading order and life cycle
Loading time
In the bootstrap startup process, service-loader is executed in the following stages:
This means:
- ✅
app.configis accessible in the service constructor (loaded) - ✅
app.loggercan be accessed in the service constructor (already initialized) - ✅ The ability to inject plugins can be accessed in the service constructor (the plugin has been setup)
- ⚠️ Pay attention to the order when accessing
app.servicesin the service constructor (see the circular dependency chapter) - ✅ All
app.servicescan be safely accessed in the routing handler (all injections have been completed)
Instantiation process
- Scanning — Recursively discover
.ts/.mts/.cts/.js/.mjs/.cjsservice files, excluding auxiliary files as described below - Sort — Sort alphabetically by file path (to ensure deterministic loading order)
- Instantiation — Create instances of
new ServiceClass(app)one by one - Mount — Wrap service methods for Service Hooks, then attach the instance to its property on
app.services - Detection — Perform circular dependency detection (optional, enabled by default)
When createTestApp() loads TS service sources directly, it uses the framework's compiler and native ESM execution without an additional TS loader. Loading a TS service again evaluates it again and creates a new instance. Its import.meta.url / filename / dirname identify the source file; owned temporary execution files are checked and cleaned before loading returns. Development and compiled production runtimes continue to use their respective compiled outputs and reload lifecycle.
Exclusion rules
The following files will be automatically skipped:
- Test files: names containing
.test.or.spec. - Declaration files:
.d.ts,.d.mts,.d.cts - Files/directories starting with
_or. - Temporary execution files with
.__vext_compiled__in the name
These exclusions do not mean arbitrary helper content belongs in the scanned directory. The current Service Loader has no dedicated exclusion branch for node_modules inside the service directory. Install dependencies at the project root and keep only service entry points in the service directory.
The _ prefix skips automatic injection, but shared utilities and type dependencies should live outside the scan directory, according to their actual consumers:
Service layer best practices
1. Keep the service layer HTTP-agnostic
The service layer should not directly operate on req / res objects. If you need to request contextual information (such as the current user), pass it in as a parameter:
2. Single responsibility
Each service corresponds to a business area. Avoid putting logic from different domains in the same service:
3. Use base classes to share common logic
Use a base class when services genuinely share behavior; a plain function is often enough for stateless logic. This example places the base class outside the scan directory. Inheritance can make static dependency analysis incomplete, so check behavior with tests rather than treating unrecognized dependencies as absent:
4. TypeScript type declaration
Add a type declaration for app.services to get full IDE support:
It is recommended to use the generation command provided by the framework first:
This command will automatically generate the VextServices extension declaration in .vext/types/services.generated.d.ts, access the TypeScript project through src/types/generated/index.d.ts, and perform a round of tooling layer service dependency checking.
vext dev runs basic typegen during preflight to keep generated declarations in sync with current services and plugins definitions. For --check, --write-manifest, or independent CI control, run vext typegen explicitly.
If you also want to provide the service index, app.extend() aggregation results and dependency graph summary to the editor, CI or other tool chain for consumption, you can additionally execute:
The corresponding artifact is .vext/manifest/services.json.
You can keep a custom .d.ts file for a few advanced declarations. Generated and handwritten files are separate, but TypeScript merges their declarations. Properties with the same name must have compatible, identical types; do not duplicate generated properties with conflicting declarations. The following manual example assumes those service files exist and is unnecessary when their properties have already been generated.
Once added, calls like app.services.user.findById() will get full method signature hints and type checking.
Instance scope and resource shutdown
Each application load creates one instance per service, shared by that application's requests. Services are not instantiated per request. Do not store the current user, request object, or temporary request result in an instance field that concurrent requests can overwrite. Each worker has its own instances and memory state.
The framework does not automatically call a service method merely because it is named close() or init(). Resource owners must register cleanup needed at application shutdown through app.onClose(). Prefer plugins to own long-lived connections; a service borrowing a connection should avoid closing it twice.
Targeted service reload in development has a separate, optional dispose() convention. The old instance being replaced or removed has that method called and awaited; a thrown error is logged as a warning and reload continues. This does not mean application shutdown automatically calls dispose(). If a later load fails, restoring the old instance reference does not reverse resource cleanup that has already happened. Make cleanup repeatable and do not assume a restored reference restores connection state. See Hot Reload.
Troubleshooting and verification
Next step
- Learn how middleware intercepts and handles requests
- Learn plugins how to extend framework capabilities
- See Testing how to unit test the service layer