Writing a service¶
Copy of
sumaris-app/.agents/rules/service.md(rule written for AI coding agents, when creating or modifying Angular services). Do not edit here: runnpm run site:sync-rulesto update it.
- Use the file suffix
.service.ts; - Use
@Injectable({ providedIn: 'root' })for application-wide singleton services, or@Injectable()if the service is provided in a specific module or component;
Base classes¶
- For a full CRUD entity service with GraphQL, extend
BaseEntityService<T, F, ID>:Tis the entity type (extendsEntity);Fis the filter type (extendsEntityFilter);IDis the identifier type (defaultnumber);- This base class provides
load(),loadAll(),watchAll(),save(),saveAll(),deleteAll(),listenChanges()out of the box;
- For a simpler service with manual GraphQL queries, extend
BaseGraphqlService<T, F, ID>; - Both base classes already inject
GraphqlServiceinternally (exposed asthis.graphql) and, forBaseEntityService,PlatformService(exposed asthis.platform) — do not pass them through your subclass's constructor;
GraphQL queries and mutations¶
- Define GraphQL queries and mutations as constants above the service class, using
gqlfromapollo-angularor@apollo/client/core:const Queries: BaseEntityGraphqlQueries = { loadAll: gql`...`, loadAllWithTotal: gql`...`, load: gql`...`, }; const Mutations: BaseEntityGraphqlMutations = { save: gql`...`, saveAll: gql`...`, deleteAll: gql`...`, }; - Use GraphQL fragments to share field definitions across queries and mutations;
- Pass queries and mutations to the base class via the options object in
super();
Constructor¶
- Never redeclare
graphql: GraphqlService(or, forBaseEntityServicesubclasses,platform: PlatformService) as a constructor parameter, and never callinject(GraphqlService)directly. The bareGraphqlServiceclass has no provider anymore (it's only provided through the internalAPP_GRAPHQL_SERVICEtoken) — redeclaring/shadowing it this way causesNG0201: No provider found for GraphqlServiceat runtime as soon as the service is instantiated; - Inject all other dependencies via
inject(), declared as class fields above the constructor (not as constructor parameters), and keep the constructor empty except for thesuper()call and any setup logic:@Injectable({ providedIn: 'root' }) export class MyEntityService extends BaseEntityService<MyEntity, MyEntityFilter> { protected accountService = inject(AccountService); protected network = inject(NetworkService); constructor() { super(MyEntity, MyEntityFilter, { queries: Queries, mutations: Mutations, }); this._logPrefix = '[my-entity-service] '; this._debug = !environment.production; } } - For
BaseGraphqlService(when not usingBaseEntityService), the pattern is the same, butsuper()only takes an optionalBaseGraphqlServiceOptions:@Injectable({ providedIn: 'root' }) export class MyService extends BaseGraphqlService { protected accountService = inject(AccountService); constructor() { super({ production: environment.production }); } } - If a service does not extend
BaseGraphqlService/BaseEntityServicebut still needs GraphQL access, inject the token directly:protected graphql = inject(APP_GRAPHQL_SERVICE);(still neverinject(GraphqlService));
Key methods to implement or override¶
load(id, opts?): Load a single entity by ID;save(entity, opts?): Save an entity (validate, convert to JSON withasObject(), call GraphQL mutation, then update the entity withcopyIdAndUpdateDate());delete(entity, opts?): Delete an entity;canUserWrite(data, opts?): Return whether the current user can write the entity;fillDefaultProperties(entity): Set default values (e.g.statusId) before saving;copyIdAndUpdateDate(source, target): CopyidandupdateDatefrom the GraphQL response back to the entity;listenChanges(id, opts?): Return anObservableto watch real-time changes via GraphQL subscriptions;
Save pattern¶
-
A typical
save()method follows this pattern:async save(entity: MyEntity, opts?: EntityServiceLoadOptions): Promise<MyEntity> { // Validate required fields if (isNilOrBlank(entity?.label)) throw new Error('Missing a required label'); // Fill defaults this.fillDefaultProperties(entity); // Convert to JSON const json = this.asObject(SAVE_AS_OBJECT_OPTIONS); // Execute mutation await this.graphql.mutate<{ data: any }>({ mutation: Mutations.save, variables: { data: json }, error: { code: ErrorCodes.SAVE_DATA_ERROR, message: 'ERROR.SAVE_DATA_ERROR' }, update: (proxy, { data }) => { const savedEntity = data?.data; if (savedEntity) { this.copyIdAndUpdateDate(savedEntity, entity); } }, }); return entity; } -
A typical
asObject()method follows this pattern:protected asObject(source: MyEntity, opts?: DataEntityAsObjectOptions): any { opts = { ...MINIFY_OPTIONS, ...opts }; const target: any = source.asObject({ ...MINIFY_OPTIONS, ...opts }); // Additional serialization override (e.g. delete some properties, that not exists in the server) // delete target.someProperty; return target; }
Error handling¶
- Use error codes from
ErrorCodesand i18n message keys for GraphQL error options; - Throw structured errors
{ code, message }for business rule violations;
Debugging¶
- Set
this._debug = !environment.productionin the constructor; - Use
this._logPrefixfor consistent log prefixes:[my-entity-service]; - Guard debug logs with
if (this._debug) console.debug(...);
Reference examples¶
- Simple service (manual queries):
src/app/referential/services/parameter.service.ts; - Full CRUD service (BaseEntityService):
src/app/data/position/device/device-position.service.ts; - Offline-capable service (RootDataSynchroService): See offline data management;