Writing services and repositories
A service is a plain class that owns one job and one logger: extend BaseService. Reading and writing one backend resource is a repository's job: extend HttpRepository from @venizia/ardor/repository. This page shows how to write both so they stay small, testable, and free of react-admin types.
Prerequisites
A running ARDOR application with a container - see the quickstart.
Quick Reference
| Export | Kind | Use it for |
|---|---|---|
BaseService | class | Any service. Takes { scope } and sets this.logger. |
HttpRepository | class | Reads and writes one backend resource over an HttpDataSource. From @venizia/ardor/repository. |
api | function | Method decorator for any BaseService. Logs a failure with the method name, then rethrows. |
ISendParams / INetworkResponse | interface | The kernel's transport contract. Enough for a service - no react-admin types needed. |
useService / useRepository | const | How a component resolves a service or a repository from the container. |
BaseService: scope + logger
BaseService is IGNIS's BaseHelper (from @venizia/ignis-helpers/core). It takes a scope and gives the service a logger, an IGNIS ILogger, that names the scope on every line it writes.
class BaseService {
scope: string;
logger: ILogger; // IGNIS's, resolved on first use
constructor(opts: { scope: string });
}Use the class name as the scope. That way the log line names the class that wrote it.
import { BaseService } from '@venizia/ardor';
export class ClockService extends BaseService {
constructor() {
super({ scope: ClockService.name });
}
now(opts: { format: 'iso' | 'ms' }): string | number {
this.logger.debug('[now] format: %s', opts.format);
return opts.format === 'iso' ? new Date().toISOString() : Date.now();
}
}Without a logger provider the lines go to the console as [scope] message. To send them elsewhere, register a provider once at startup with LoggerFactory.use from @venizia/ignis-helpers; every this.logger follows it. A test can install its own logger on one instance: service.logger = ....
A resource: HttpRepository
A class about one backend collection, such as products or orders, is a repository. HttpRepository reads and writes it through an HttpDataSource with the IGNIS filter vocabulary, and reads totals from the Content-Range header.
import { HttpDataSource, HttpRepository } from '@venizia/ardor/repository';
interface IProduct {
id: number;
price: number;
}
export class ProductRepository extends HttpRepository<IProduct> {
constructor(opts: { dataSource: HttpDataSource }) {
super({ dataSource: opts.dataSource, resource: 'products' });
}
countAbove(opts: { price: number }) {
return this.count({ where: { price: { gte: opts.price } } });
}
reprice(opts: { id: number; price: number }) {
return this.updateById({ id: opts.id, data: { price: opts.price } });
}
}See the repository reference for the datasource settings, the request each write sends, and what the repository refuses to guess.
Options objects on every method
Every constructor here takes a single options object. Do the same on every public method you add. getPrice({ id }), never getPrice(id). Callers can add a field later without breaking every call site, and the argument names show up at the call site.
The one exception is the react-admin facing IDataProvider the container returns. Its methods are positional (resource, params) because react-admin calls them. That contract belongs to the provider, not to your service.
@api() for logging failures
api() is a method decorator for any BaseService subclass. It wraps the method, awaits it, and on failure logs one error line with the method name, the class's resource if it has one (- otherwise), and the error. Then it rethrows. It does not swallow the error and it does not change the result on success.
function api(): (
_target: BaseService,
propertyKey: string,
descriptor: PropertyDescriptor,
) => PropertyDescriptor;The logged line looks like [getPrice] resource: products | error: .... You get this for free on every method you decorate, so you do not write a try/catch just to log.
import { api, BaseService } from '@venizia/ardor';
export class PricingService extends BaseService {
protected resource = 'products';
constructor() {
super({ scope: PricingService.name });
}
@api()
async getPrice(opts: { id: number }): Promise<number> {
const response = await fetch(`/${this.resource}/${opts.id}/price`);
if (!response.ok) {
throw new Error(`price lookup failed with ${response.status}`);
}
return response.json();
}
}Return response.data
When a method goes through the kernel transport, unwrap the response before returning. The caller wants the payload, not the envelope. Keep the envelope handling inside the service.
import { api, BaseService, ISendParams, INetworkResponse } from '@venizia/ardor';
export class PricingService extends BaseService {
...
@api()
async list(opts: ISendParams): Promise<INetworkResponse['data']> {
const response = await ... // your transport call, see the network reference
return response.data;
}
}Throw with getError
The kernel throws its own errors with getError from @venizia/ignis-inversion, using an options object:
import { getError } from '@venizia/ignis-inversion';
...
throw getError({ message: '[info] Invalid logger instance!' });Do the same in a service. Throw, do not return null or undefined on failure. @api() will log it, and the caller decides what to do.
Keep react-admin types out
A service in the kernel layer should not import from ra-core. GetListParams, RaRecord, and friends belong to IReactAdminDataProvider in the admin package. That is the adapter between react-admin and your services.
Inside a service, ISendParams and INetworkResponse are enough. They describe what goes over the wire. If a service signature needs a react-admin type, the react-admin logic has leaked one layer too far. Move it into the data provider.
One instance per application
A service is registered once in the container and lives for the life of the application. Every component that resolves it gets the same object.
So do not keep per-request state on the class. No this.currentId, no this.lastResult. Pass what a call needs in its options object and return what it produced. The only long-lived field a service should hold is what it was built with: logger, resource, and injected collaborators.
Where a service lives and how a component reaches it
Register the class by stereotype or by hand (this.service(PricingService), this.repository(ProductRepository)); either binds a singleton and records the key on the class. A component does not import the class and new it. It asks the container by class, and the hook checks the class is bound under services. or repositories..
import { useRepository, useService } from '@venizia/ardor';
export function ProductPrice(props: { id: number }) {
const pricing = useService({ target: PricingService });
const products = useRepository({ target: ProductRepository });
...
}The component never sees how the class was built. Swap the implementation in the container and the component does not change. See binding keys for how to name keys and hooks for the hook signatures.
Common pitfalls
- Expecting
@api()to swallow errors. It logs and rethrows. The caller still gets the error. - Decorating a sync method.
@api()replaces the method with anasyncfunction. The result is always a promise. - Reusing a scope. Two classes with one scope are indistinguishable in the log.
- Hiding debug lines by accident. Each level is written through the console method of the same name (
console.debug,info,warn,error). Browsers hideconsole.debugunder the default filter, so enable the Verbose level to see debug output. - Storing request state on the class. The service is a singleton. State on the instance is shared by every caller.
Related
- Binding keys - naming and registering services and repositories in the container
- Hooks -
useService,useRepositoryand the other React hooks - Network - the transport a service calls
- Data provider - where react-admin types belong
- Types -
ISendParams,INetworkResponse, and other kernel types - Module augmentation - typing your own binding keys