craftService
A service is a factory with a name and a scope — not a class. It packages primitives and dependencies behind an explicit API, and keeps the whole dependency graph visible to the compiler.
Use it when logic outgrows a single component field, or when two places need the same behaviour. Not when you are adapting an existing Angular service or token — that is toCraftService.
The contrast with inject(...) scattered across classes is the point: dependencies here are explicit and type-visible, which is what the route DI check and the test registers read.
import { craftService } from '@craft-ng/core';API still moving
This API will be aligned with the others — made yieldable, so source$ is tracked as a dependency.
What you get
Declaring a service gives you a set of generated helpers. For one named Counter:
Counter(...)— consume or compose it inside a craft generatorCounter.someProperty(...)— derive one public property directlyprovideCounter(...)— for provider-capable scopesCOUNTER_META_DATA— for metadata-driven toolingCounterRequirement— forabstractservicesprovideCounter(factory)— onabstractservices, to implement the contract inline
Which of those exist depends on the scope.
Breaking change — no more injectX
The generated helper is the service name itself: X. craftService no longer exports injectX, and the former XToYield helper is gone. Use X() in a craft generator and compose with yield* X().
Supported scopes
A service declares how many instances of it exist through scope: function, toProvide, global, manuallyProvidedAtRoot or abstract. Default to function.
Each scope and when to pick it: Service scopes.
The common case
import { craftService, state } from '@craft-ng/core';
const { Counter } = craftService(
{ name: 'Counter', scope: 'global' },
function* () {
const counter = yield* state('counter', 0, ({ update }) => ({
increment: () => update((value) => value + 1),
decrement: () => update((value) => value - 1),
}));
return counter;
},
);
const { CounterConsumer } = craftService(
{ name: 'CounterConsumer', scope: 'global' },
function* () {
const counter = yield* Counter();
counter.increment();
return counter;
},
);Returning one primitive directly
When a service exposes only one primitive, the factory can return its generator directly. craftService drives it and the generated service helper returns the primitive reference:
import { craftService, query } from '@craft-ng/core';
const { UserQuery } = craftService(
{ name: 'UserQuery', scope: 'global' },
(inputs: { userId: () => string | undefined }) =>
query('userQuery', {
params: inputs.userId,
loader: ({ params }) => ApiService.getItemById(params),
}),
);For several primitives, use craftYieldRecord. It resolves every generator in the record and preserves the record keys:
import {
craftService,
craftYieldRecord,
query,
state,
} from '@craft-ng/core';
const { UserQuery } = craftService(
{ name: 'UserQueryWithState', scope: 'global' },
(inputs: { userId: () => string | undefined }) =>
craftYieldRecord({
userQuery: query('userQuery', {
params: inputs.userId,
loader: ({ params }) => ApiService.getItemById(params),
}),
refresh: state('refresh', 0, ({ update }) => ({
increment: () => update((value) => value + 1),
})),
}),
);Inside a generator factory, the equivalent explicit form remains available: const userQuery = yield* query(...).
Scoping providers to the service
Use providers in the service config when the service factory itself needs locally-scoped dependencies:
const { UserFacade } = craftService(
{
name: 'UserFacade',
scope: 'global',
providers: [provideUserApi(), provideUserLogger()],
},
function* () {
const api = yield* UserApi();
const logger = yield* UserLogger();
return {
rename: (user: { id: string; name: string }, name: string) => {
logger.log(`rename:${user.id}`);
return api.updateUser({ ...user, name });
},
};
},
);This is separate from provideUserFacade(), which is only generated for provider-capable scopes like toProvide.
Composing services
const { Counter } = craftService(
{ name: 'Counter', scope: 'global' },
function* () {
const counter = yield* state('counter', 0, ({ update }) => ({
increment: () => update((value) => value + 1),
}));
return counter;
},
);
const { CounterFacade } = craftService(
{ name: 'CounterFacade', scope: 'global' },
function* () {
const counter = yield* Counter();
return {
read: () => counter(),
increment: () => counter.increment(),
};
},
);Shaping the public API
yield* X() can expose only part of a dependency, and X.property() derives a single one. See Shaping a service's public API.
Contracts without an implementation
scope: 'abstract' declares a contract that a provider must satisfy later. See Abstract services.
Startup work
craftService also supports startup hooks through appStart: true and yield* onAppStart(...).
The callback can be a plain function or a generator function. Use the generator form when startup logic needs to yield* crafted dependencies:
import { Console, craftService, onAppStart } from '@craft-ng/core';
const { AppStartLog } = craftService(
{
name: 'AppStartLog',
scope: 'global',
appStart: true,
},
function* () {
yield* onAppStart(function* () {
yield* Console.log('startup log');
return Promise.resolve();
});
return true;
},
);
// register the current service to the AppStartRegistry
// it is auto-generated when used with the craft-ng ESLint plugin
declare module '@craft-ng/core' {
interface CraftAppStartRegistry {
AppStartLog: typeof AppStartLog;
}
}
// inside craftAppConfig
export const appConfig = craftAppConfig({
appStart: {
AppStartLog,
},
});Dependencies used only inside that callback are still tracked on the parent service.
Pitfalls
Reaching for global by default. A global service is a singleton for the whole app, whether or not that was intended. Start at function — see Service scopes.
toProvide without the provider. Angular does not report a missing provider at compile time; the failure appears at runtime. The route DI check is what closes that hole.
Returning the whole world. What a service returns is its API. Return the narrow thing; consumers that need more can yield more.
Calling inject() inside a craft factory. It works and it is invisible to every check that makes this worthwhile. The craft-ng/no-angular-inject rule exists for exactly this.
See Also
- Service scopes — the one decision to make
- Shaping the public API
- Testing services