craftService
Creates a named Angular-friendly service boundary with generated inject, yield, provider, and metadata helpers.
WARNING
I will try to align this API with others (make it yieldable in order to track source$ as a dependency).
Import
import { craftService } from '@craft-ng/core';Introduction
craftService is the service-oriented composition API for @craft-ng.
It lets you:
- define a service once with a stable name and scope
- inject it through a generated
injectX()helper - compose it into another service through
yield* XToYield() - expose only part of a dependency through derived bindings
- generate typed provider helpers for provider-capable scopes
- keep the full dependency graph available for testing utilities
Unlike ad-hoc inject(...) calls spread across services, craftService makes dependencies explicit and type-visible.
Generated Helpers
For a service named Counter, craftService can generate:
injectCounter(...)to resolve the service in components/directivesCounterToYield(...)to compose it inside anothercraftServiceCounterToYield.someProperty(...)to derive one public property directlyprovideCounter(...)for provider-capable scopesCOUNTER_META_DATAfor metadata-driven toolingCounterRequirementforabstractservices
The exact helpers depend on the chosen scope.
Supported Scopes
global
- singleton provided at root
- ideal for app-wide services and shared state
- no explicit
provideX()helper
toProvide
- requires
provideX()where the service is mounted - useful for feature-local service trees
- works well with tests that need explicit providers
manuallyProvidedAtRoot
- explicit provider helper, but designed to be mounted at root
- also exposes
XToProvidefor public provider composition - allows this scope to be yielded by global services, which is not possible with
toProvide(it still requires explicit setup when testing withsetupCraftServiceTestingByRegister).
function
- creates a fresh instance on each injection
- useful for reusable factories with bindings and inputs
abstract
- declares a contract without implementation
- exposes a requirement token to force a concrete implementation later
Recommendations For Choosing a Scope
- Prefer
functionfor a service owned by a single component. It avoids an explicit provider and makes it clear the instance is not meant to be shared with other components or child components. - Move to
toProvidewhen the same instance must be shared with child components, or across several components through a common parent or route. In that case, provide it at the component boundary, a parent component, or the route. - Be careful with
toProvide: Angular does not report a compilation error when the provider is missing, so the failure usually appears at runtime instead. - Use
globalwhen the instance is intentionally shared application-wide. - For startup-only logic that should run when the app boots but is not injected elsewhere, prefer
functiontogether withprovideAppInitializer(...). If the same instance also needs to be injected by other services, useglobalinstead.
App Start
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 { injectAppStartLog } = 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 injectAppStartLog;
}
}
// inside craftAppConfig
export const appConfig = craftAppConfig({
appStart: {
AppStartLog: injectAppStartLog, // an error is thrown if AppStartLog is not injected here
},
});Dependencies used only inside that callback are still tracked on the parent service.
Basic Example
import { craftService, state } from '@craft-ng/core';
const { injectCounter } = craftService(
{ name: 'Counter', scope: 'global' },
() =>
state(0, ({ update }) => ({
increment: () => update((value) => value + 1),
decrement: () => update((value) => value - 1),
})),
);
const counter = injectCounter();
counter.increment();
console.log(counter());Add providers to craftService
Use providers in the service config when the service factory itself needs locally-scoped dependencies:
const { injectUserFacade } = craftService(
{
name: 'UserFacade',
scope: 'global',
providers: [provideUserApi(), provideUserLogger()],
},
function* () {
const api = yield* UserApiToYield();
const logger = yield* UserLoggerToYield();
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.
Composition With yield*
const { CounterToYield } = craftService(
{ name: 'Counter', scope: 'global' },
() =>
state(0, ({ update }) => ({
increment: () => update((value) => value + 1),
})),
);
const { injectCounterFacade } = craftService(
{ name: 'CounterFacade', scope: 'global' },
function* () {
const counter = yield* CounterToYield();
return {
read: () => counter(),
increment: () => counter.increment(),
};
},
);Single Property Shortcut
When only one public property is needed, XToYield.property() is a shortcut for a one-property derivation.
const { UsersApiToYield } = craftService(
{ name: 'UsersApi', scope: 'global' },
() => ({
updateUser: (user: { id: string; name: string }) => Promise.resolve(user),
getUsers: () => Promise.resolve([]),
}),
);
const { injectUserUpdater } = craftService(
{ name: 'UserUpdater', scope: 'global' },
function* () {
const updateUser = yield* UsersApiToYield.updateUser();
return {
rename: (user: { id: string; name: string }, name: string) =>
updateUser({ ...user, name }),
};
},
);For method properties on services without public inputs, the shortcut can call the method directly:
return yield * UsersApiToYield.updateUser({ id: '1', name: 'Romain' });The shortcut accepts the same bindings as XToYield(...):
const increment = yield * CounterToYield.increment({ initialValue: 0 });Use the full XToYield(bindings, expose) form when deriving several properties, creating aliases, exposing $self, using symbol keys, or when a service property collides with a native function property such as name.
Inject Single Property Shortcut
The same shortcut notation is available on the injectX helper in components and directives. Instead of selecting a single property through a selector function, use injectX.property():
const { injectUsersApi } = craftService(
{ name: 'UsersApi', scope: 'global' },
() => ({
updateUser: (user: { id: string; name: string }) => {},
currentUser: signal<{ id: string } | null>(null),
}),
);
@Component({ ... })
class UserComponent {
// equivalent to injectUsersApi(undefined, ({ currentUser }) => ({ currentUser })).currentUser
readonly currentUser = injectUsersApi.currentUser();
}The result carries the same dependency tracking as any other inject shortcut, so testing utilities see exactly which property was accessed.
For method properties on services without public inputs, the shortcut calls the method directly:
protected readonly update = injectUsersApi.updateUser({ id: '1', name: 'New' });Nested Property Shortcuts
When only a sub-property of a service output is needed, add a second .property before calling, for both injectX and XToYield:
const { injectSearchApi, SearchApiToYield } = craftService(
{ name: 'SearchApi', scope: 'global' },
() => ({
usersQuery: {
isLoading: signal(false),
data: signal<string[]>([]),
},
}),
);
// In a component
@Component({ ... })
class SearchComponent {
readonly isLoading = injectSearchApi.usersQuery.isLoading();
}
// In a service
const { injectSearchFacade } = craftService(
{ name: 'SearchFacade', scope: 'global' },
function* () {
const isLoading = yield* SearchApiToYield.usersQuery.isLoading();
return { isLoading };
},
);The dependency graph records only the accessed nested property (derivedPropertiesUsed: { usersQuery: { isLoading: ... } }), not the full usersQuery object. Testing utilities therefore only require the used sub-property in mock objects.
The result of injectX.parent.child() carries WithTrackedDependencies just like any other inject helper result, so ExtractDeps on a component field built from it will correctly surface the service dependency.
OmitInputs
When a service has public inputs, the no-arg form of a property shortcut is intentionally disabled at the type level, because calling without bindings would silently use default values and mask a missing dependency:
const { injectCounter, CounterToYield } = craftService(
{ name: 'Counter', scope: 'function' },
(inputs: { initialValue?: MaybeSignal<number> }) => ({
count: toValue(inputs.initialValue) ?? 0,
}),
);
// Fine — bindings are explicit
const count = injectCounter.count({ initialValue: signal(5) });
// Type error — no-arg call is forbidden when inputs exist
// injectCounter.count();Use injectX.OmitInputs.property() or XToYield.OmitInputs.property() to explicitly opt out of input bindings and use the defaults:
const count = injectCounter.OmitInputs.count();
const count2 = yield * CounterToYield.OmitInputs.count();OmitInputs is purely a type-level gate — at runtime it is transparent.
OmitInputs composes with nested shortcuts:
const isLoading = injectCounter.OmitInputs.userQuery.isLoading();Partial Exposure
yield* XToYield() can expose only the part of a dependency that should remain public.
const { CounterToYield } = craftService(
{ name: 'Counter', scope: 'toProvide' },
() =>
state(0, ({ update }) => ({
increment: () => update((value) => value + 1),
decrement: () => update((value) => value - 1),
})),
);
const { injectCounterExtended, provideCounterExtended } = craftService(
{ name: 'CounterExtended', scope: 'toProvide' },
function* () {
return yield* CounterToYield(undefined, ({ $self, increment }) => ({
$self,
incrementCounter: increment,
}));
},
);This keeps the dependency graph precise, which is important for both type inference and testing.
Abstract Requirements
Use scope: 'abstract' to declare a contract that must be implemented elsewhere.
import { abstract, craftService } from '@craft-ng/core';
type CounterContract = {
(): number;
increment(): void;
};
const { CounterRequirement } = craftService(
{ name: 'Counter', scope: 'abstract' },
abstract<CounterContract>(),
);Concrete services can then depend on CounterRequirement.
Testing
Complementary testing helper are designed around craftService metadata:
- setupCraftServiceTestingByRegister for exhaustive flat registers