6. Provide Layers and route the app
Goal: make Effect requirements explicit at the same scopes as your Craft injectors.
Provide one application Layer
provideLayer builds an Effect context and stores it on the Craft injector:
import { Layer } from 'effect';
import { provideLayer } from '@craft-ts/effect';
export const appConfig = craftAppConfig({
providers: [
provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive)),
],
});The Layer is built once at that injector level. Child injectors reuse the parent context and add their own services.
Add a route Layer
Inline route providers in loadCraftComponent(...); the compile-time proof preserves and inspects the tuple from the typed route collection:
const routes = craftRoutes('app', [
{
path: 'team',
...loadCraftComponent(
() => import('./team').then(({ default: component }) => component),
[provideLayer(SupportTeamLive)] as const,
),
},
]);The team query can require SessionService | TeamContextService while the component only sees TeamOverview. Route-scoped resources are closed when the route injector is destroyed, so Layer scopes do not leak across navigation.
Layer scopes follow Craft provider scopes
provideLayer(...) is a normal Craft provider, so the same Effect context can be attached at every Craft scope that accepts providers:
| Scope | Where to put provideLayer(...) | Lifetime and visibility |
|---|---|---|
| Application | appConfig.providers | shared by the whole application |
| Route | the route's providers array | shared by that route and its children |
| Component | craftComponent meta providers | limited to that component subtree |
| Primitive | a primitive config's providers | limited to that primitive |
| Insertion | the containing primitive's providers | inherited by its insertion callbacks and methods |
For example, a component or a primitive can provide a local implementation without changing the application Layer:
const Profile = craftComponent(
'Profile',
{ providers: [provideLayer(AccessPolicyLive)] },
/* … */
);
const profile = yield* queryEffect('profile', {
providers: [provideLayer(AccessPolicyLive)],
params: () => 'user-ada',
loader: ({ params }) => checkUserAccess(params),
});An insertion receives the primitive's injector, so its generators and methods see the primitive's Layer as well. There is no separate provideLayer argument on an insertion today; use the containing primitive's providers to scope it. If two services must be provided at the same scope, merge them into one Layer:
providers: [provideLayer(Layer.mergeAll(AccessPolicyLive, SessionLive))]Child scopes inherit the parent context and can add a more local implementation of a service. Their scopes are closed with the corresponding Craft injector.
Prove requirements at compile time
Effect requirements are not regular Craft services, so add an explicit proof:
import type { Effect } from 'effect';
import type { AppProvidedDependencyValuesOf, CanRun } from '@craft-ts/core';
import type {
EffectRequirementsCheckedDI,
ProvidedEffectServicesOfRoute,
} from '@craft-ts/effect';
type AppProvidedEffectServices = AppProvidedDependencyValuesOf<
typeof appConfig
>;
type CheckTeam = EffectRequirementsCheckedDI<
Effect.Services<typeof loadTeamOverview>,
AppProvidedEffectServices |
ProvidedEffectServicesOfRoute<typeof routes._routes, 'team'>
>;
type CanRunTeam = CanRun<CheckTeam>;Remove SupportTeamLive and CanRunTeam becomes a useful type error naming the missing Effect service. This is the Effect equivalent of Craft's RouteCheckedDI.
Route errors are still exhaustive
queryEffect and runEffect make tagged Effect errors visible to Craft's route exception analysis. Keep the route map exhaustive:
const { routes } = craftRoutes('app', [
{
path: '',
...loadCraftComponent(() => import('./profile')),
handleExceptions: {
UserNotFound: craftExceptionHandler(/* … */),
Unauthorized: craftExceptionHandler(/* … */),
},
},
]);
assertExhaustiveRouteExceptions(routes);Keep URL state in Craft
URL state is a UI concern, so it stays a native queryParams primitive even in an Effect application:
import { Schema } from 'effect';
const Search = Schema.String;
const searchCodec = {
decode: Schema.decodeUnknownSync(Search),
encode: Schema.encodeSync(Search),
};
const filters = yield* queryParams('filters', {
state: {
search: {
fallbackValue: '',
codec: searchCodec,
},
},
});
const users = yield* queryEffect('users', {
params: () => filters(),
loader: ({ params }) => searchUsers(params),
});The codec remains synchronous, as required by queryParams, but validation and encoding now come from an Effect Schema. Replace Schema.String with a transformation schema when the URL representation differs from the value used by the component.
There is no queryParamsEffect: Craft synchronises the URL, while the Effect loader reacts to the resulting typed params.
What you gained
Effect Layers now follow Craft's app and route scopes, their requirements are checked, and typed Effect failures cannot silently disappear at a route.