Effect.provideServiceEffect
In the previous two lessons we saw how defining and providing services helps us write simple, testable code, even when dealing with side effects like randomness or network access. In this lesson we’ll see how Effect.provideServiceEffect
helps us combine services together, giving us great composition benefits as our application grows.
Let’s start with a very basic service: logging. console.log
is OK for simple programs, but in a complex application we might want more structure. For example, a Node app might want to write logs to a file instead of to stderr, with different parts of the app writing to different files. We might want to enhance our log messages with extra formatting or processing.
Here’s a simple app with a very basic LogService
. Note that our “main” app does not need to care how the log service is implemented. We could swap out any implementation we want, as long as it can log.
import { Context, Effect } from "effect";
const sleep = (ms: number) =>
Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms)));
interface Logger {
log: (message: string) => Effect.Effect<void>;
}
class LogService extends Context.Tag("LogService")<LogService, Logger>() {}
const plainLogger = {
log: (message: string) => Effect.sync(() => console.log(message)),
};
const main = Effect.gen(function* () {
const logger = yield* LogService;
yield* logger.log("starting");
yield* sleep(1000);
yield* logger.log("finished first piece");
yield* sleep(1000);
yield* logger.log("done!");
});
main.pipe(
Effect.provideService(LogService, plainLogger),
Effect.runPromise,
);
But what happens if creating the logger itself requires running an effect? For example, we might need to access the file system. Let’s say we want to print the total time elapsed, to help us debug a performance issue in our app.
const timestamp = Effect.sync(() => +new Date());
// ┌─── Effect.Effect<Logger>
// ▼
const makeLogger = Effect.gen(function* (_) {
const start = yield* timestamp;
return {
log: (message) =>
Effect.gen(function* () {
const now = yield* timestamp;
const elapsed = (now - start) / 1000;
yield* plainLogger.log(`[after ${elapsed}s] ${message}`);
}),
} satisfies Logger;
});
// cannot do this: `makeLogger` is not a Logger, it's an Effect<Logger>
// Effect.provideService(LogService, makeLogger)
const wrappedMain = Effect.gen(function* () {
// setup
const logger = yield* makeLogger;
// run
yield* main.pipe(Effect.provideService(LogService, logger));
});
wrappedMain.pipe(Effect.runPromise);
We cannot call Effect.provideService
with makeLogger
, because makeLogger is not a Logger - it’s an Effect that produces a Logger!
Instead we have to wrap our main application with setup.
It turns out that having effectful service constructors is very common: they may need to read config files, initialise database connections, prepare a cache, or access some other service.
Effect.provideServiceEffect
is a shortcut for exactly this.
main.pipe(
Effect.provideServiceEffect(LogService, makeLogger),
Effect.runPromise,
);
Up until now we have been thinking of pipelines as programs that run from top to bottom. It might seem odd that makeLogger
appears below main
, even though making a logger needs to happen first.
Recall what pipe is actually doing: wrapping function calls. We could rerite the run logic like this:
const withLogger = Effect.provideServiceEffect(LogService, makeLogger);
const run = Effect.runPromise;
run(withLogger(main));
main
gets passed into withLogger
, allowing withLogger to “wrap” main.
Note that we can write Effect.provideServiceEffect
in terms of Effect.provideService
, and vice versa. Here are simplified implementations:
const provideServiceEffect =
<I, S>(tag: Context.Tag<I, S>, make: Effect.Effect<S>) =>
<A>(self: Effect.Effect<A, never, I>) =>
Effect.gen(function* () {
const service = yield* make;
return yield* self.pipe(Effect.provideService(tag, service));
});
const provideService = <I, S>(tag: Context.Tag<I, S>, service: S) =>
Effect.provideServiceEffect(tag, Effect.succeed(service));
Effect.provideServiceEffect
is helpful for implementing single services, as above, but its real power is that it helps us decouple services from each other.
In the previous lesson we saw how simple it was to test a variety of scenarios in our random game. Code that used the GameService was easy to test. But what about our GameService implementation itself?
const random = Effect.sync(() => Math.random());
const randomGame = {
roll: random.pipe(Effect.map((v) => Math.ceil(v * 6))),
flip: random.pipe(Effect.map((x): Coin => (x > 0.5 ? "HEADS" : "TAILS"))),
};
Note that this implementation is tightly coupled to the random implementation. Our important “business logic” is admittedly quite simple here: a dice roll is from 1-6 and the coin flip is heads or tails. But we can imagine how this service could be much more complicated.
First, let’s define a new RandomService.
class RandomService extends Context.Tag("RandomService")<
RandomService,
{ next: Effect.Effect<number> }
>() {}
const randomImpl: Context.Tag.Service<RandomService> = {
next: Effect.sync(() => Math.random()),
};
const randomGame: Game = {
roll: randomImpl.next.pipe(Effect.map((v) => Math.ceil(v * 6))),
flip: randomImpl.next.pipe(
Effect.map((x): Coin => (x > 0.5 ? "HEADS" : "TAILS")),
),
};
If we tried to use the RandomService directly, we would not be able to implement a Game.
This is because our Game expects roll
to be an Effect<number, never, never>
.
// ┌─── Effect<number, never, RandomService>
// ▼
const roll = RandomService.pipe(
Effect.flatMap((svc) => svc.next),
Effect.map(v => Math.ceil(v * 6)),
);
We could adjust the interface of roll
, but this is not a good idea. It leaks implementation details!
Consumers of our GameService
should not need to care that we are using a RandomService
behind the scenes.
It would also be a breaking change, if GameService
was part of a library.
We cannot produce a Game
using RandomService
- at least, not without running an Effect - but we can produce an Effect<Game>
.
Observe that we could test makeGameService
using a fake RandomService
of our choosing, letting us see how game.roll
and game.flip
behave in a range of controlled scenarios.
const makeGameService = Effect.gen(function* (_) {
const random = yield* RandomService;
const game: Game = {
roll: random.next.pipe(Effect.map((v) => Math.ceil(v * 6))),
flip: random.next.pipe(
Effect.map((x): Coin => (x > 0.5 ? "HEADS" : "TAILS")),
),
};
return game;
});
program.pipe(
Effect.provideServiceEffect(GameService, makeGameService),
Effect.provideService(RandomService, randomImpl),
Effect.runSync,
);
We are free to mix and match provideService
and provideServiceEffect
.
As noted above, the order of “provide” calls might look to be upside down, because we need to obtain randomImpl
before we can makeGameService
.
Again, it may be clearer to rewrite using plain function calls without pipe.
const withRandom = Effect.provideService(RandomService, randomImpl);
const withGame = Effect.provideServiceEffect(GameService, makeGameService);
const run = Effect.runSync;
run(withRandom(withGame(program)));
We can think of withRandom
as providing a “scope” where the random service is available, and withGame
is called inside that scope, so it is able to access the random service.
run(withGame(withRandom(program)))
would give a TypeScript error - there is no RandomService
available in scope when withGame
tries to initialise the GameService
.
Let’s put all of this together and see for the first time how an Effect app can spread across multiple files. There’s no new code, but I’ve rearranged a few parts to be more dense.
// --- RandomService.ts ---
import { Effect, Context } from "effect";
const randomImpl = {
next: Effect.sync(() => Math.random()),
};
export class RandomService extends Context.Tag("@myapp/RandomService")<
RandomService,
typeof randomImpl
>() {
static Live = this.of(randomImpl);
static Test = this.of({
next: Effect.succeed(0.5),
});
}
Note that this RandomService has no “application” logic specific to our game, and no dependencies apart from Effect itself. We could reuse this RandomService in any Effectful application.
// --- GameService.ts ---
import { Effect, Context } from "effect";
import { RandomService } from "./RandomService";
type Coin = "HEADS" | "TAILS";
const nextRandom = RandomService.pipe(Effect.flatMap((svc) => svc.next));
const roll = nextRandom.pipe(Effect.map((v) => Math.ceil(v * 6)));
const flip = nextRandom.pipe(
Effect.map((x): Coin => (x > 0.5 ? "HEADS" : "TAILS")),
);
const makeGameService = Effect.gen(function* () {
const random = yield* RandomService;
return {
roll: Effect.provideService(roll, RandomService, random),
flip: Effect.provideService(flip, RandomService, random),
};
});
export class GameService extends Context.Tag("@myapp/GameService")<
GameService,
Effect.Effect.Success<typeof makeGameService>
>() {
static makeShallow = makeGameService;
static make = this.makeShallow.pipe(
Effect.provideService(RandomService, RandomService.Live),
);
static Test = this.of({
roll: Effect.succeed(6),
flip: Effect.succeed("HEADS"),
});
}
All the mechanics of our game builder are here.
The RandomService doesn’t expose exactly the interface we want: ideally nextRandom
would live directly in RandomService as RandomService.next
.
If we are able to change RandomService directly then we should, but on a large project - or if using a third party library from the Effect ecosystem - this might not be possible.
Still, we are able to work around it fairly easily.
In this implementation I’ve chosen to expose both GameService.make
and GameService.makeShallow
.
The “shallow” version has the advantage that it lets consumers decide how to provide how to a RandomService
, which can make testing more deterministic.
The “regular” (non-shallow) version hard-codes the implementation to RandomService.Live
. This has the advantage of encapsulation.
Consumers are completely unaware that GameService
uses RandomService
behind the scenes.
This gives us greater ability to refactor the internal implementation of GameService
.
Ultimately, it’s a trade-off. You can expose either or both.
Finally, our “main” program.
// --- main.ts ---
import { Effect, Data } from "effect";
import { GameService } from "./GameService";
class YouLose extends Data.TaggedError("@myapp/YouLose")<{}> {}
const program = Effect.gen(function* () {
const game = yield* GameService;
let total = yield* game.roll;
const flip = yield* game.flip;
if (flip === "HEADS") {
total += yield* game.roll;
}
if (total < 5) {
yield* new YouLose();
}
return total;
});
program.pipe(
Effect.provideServiceEffect(GameService, GameService.make),
Effect.runSync,
);
Note that this module does not refer to RandomService
anywhere.
If we had changed the interface of GameService.roll
then this would not have been possible.
In general service members should have an environment of never
.
This pushes the job of providing an environment to the function that creates an instance (makeGameService
in this example).
In some applications it makes sense to “stitch together” all the services in one place.
We could have achieved that here by using GameService.makeShallow
and providing a RandomService
implementation.
This stitching can get complicated when there are many interdependent services.
Usually it is better for a service’s effectful constructor to be colocated alongside the service definition.
This is still a fairly small application, but we can see how each of the pieces of Effect help us to compose larger applications.
Each piece of the application can be tested, modified, or swapped out with a straightforward change.
program
is itself an Effect, so we could include it as part of a larger program - perhaps one that allows players to retry when they lose, or allows two players to compete against each other for a high score.
Let’s recap:
Effect.provideServiceEffect
allows constructing service implementations effectfully- services can depend on other services
- use services as conceptual boundaries as your application grows
- services should expose Effects with requirement type of
never