Summary
At Inato, we migrated from fp-ts to Effect in early 2024. Given our substantial codebase (around 500k lines of typescript code), we needed a way of ensuring any new code could be written using Effect while allowing existing fp-ts code to coexist. We achieved this goal in just two months, dedicating around 10% of our time to it. In this article, you will find our detailed migration strategy, the helpers we developed (which you can find in this repository), and how we ensured a smooth transition of our codebase.
Migrate to Effect, why?
At Inato we were very motivated early on to adopt functional programming, so we started using fp-ts in our codebase at the beginning of 2020. If you want to know more about this, have a look at Our journey to functional programming.
Let’s now get to the heart of the matter: at the beginning of this year, we officially decided to switch to Effect! Why?
- The main maintainer of fp-ts (gcanti 👋) joined the Effect team which presumably means less active development on the fp-ts side and positions Effect as a rather obvious next step.
- Because of the learning curve associated with fp-ts and the lack of documentation. Developers who joined Inato in the last years have frequently mentioned it: learning fp-ts is not really straightforward. This is a strong point for Effect with top-notch documentation and a lot of resources for training.
- For even more reasons, visit the Effect website which compares fp-ts and Effect!
Migrating our codebase to Effect is a great goal, but doing it turned out to be more challenging and required careful planning. We also wanted to limit the time spent on this project, so we agreed on a 2.5-month deadline. With all this in mind, we came up with the following strategy.
The Migration Strategy
First of, here’s a representation of our server-side codebase: we have use cases that represent our business actions, these use cases have multiple dependencies (services, repositories, etc. — we’ll refer to them as ports), and we also have runners that will execute our use cases:
When we started the migration we had around 400 use cases and 80 ports and their adapters to migrate.
Our objective for this migration was clear: by the end of our 2.5-month window, any new use case or port will be written using Effect. To have a smooth transition that would allow us to have fp-ts and Effect code cohabitating, we came up with the following plan:
- Ensure our ports return
ReaderTaskEither
to facilitate the transition to Effect [*] - Create Effect proxies of our ports: only one implementation in fp-ts, but the ability to use an fp-ts “real” version and an Effect proxy version of each port
- Start (re)writing use cases in Effect
- Create fp-ts proxies of Effect use cases
- Start (re)writing ports in Effect
- Create fp-ts proxies of Effect ports: at this point, we would already have fulfilled our objective of writing new use cases and ports with Effect. But we wanted to go the extra mile to have the full flow covered!
- Be able to run both Effect and fp-ts use cases
[*] ReaderTaskEither
(we will refer to it as RTE
later on) was a prerequisite to facilitate the migration to Effect. Why? Conceptually, a ReaderTaskEither
can be represented as follows:
ReaderTaskEither<R, E, A>
= Reader<R, TaskEither<E, A>>
= (context: R) => () => Promise<Either<E, A>>
If we look at the representation of an effect given on the official Effect website, we can see that these are very similar concepts (which is something that we will leverage during our migration):
Effect<A, E, R> ~ (context: Context<R>) => E | A
The Migration Process
Let’s deep dive into the code now! Here are the steps we are going to follow:
- The program to migrate
- Create Effect proxies of the ports
- Rewrite a use case in Effect
- Convert ports to Effect
- Use ManagedRuntime to run Effect usecases
- Bonus: simplify fp-ts ↔ effect tag mapping management
To illustrate our migration process, we will focus on an example program that is representative of how our codebase is organized.
Note: all the code and helpers that will be presented are available in 👉 this repository 👈
The program to migrate
Let say that our domain model is composed of a simple Foo
class:
// domain.ts
export class Foo {
constructor(readonly id: string) {}
static make = (id = "random-id") => new Foo(id);
}
We define a repository port to get and store a Foo
:
// FooRepository.ts
export interface FooRepository {
getById: (id: string) => RTE<unknown, Error, Foo>;
store: (foo: Foo) => RTE<unknown, Error, void>;
}
export interface FooRepositoryAccess {
fooRepository: FooRepository;
}
export declare const FooRepository: {
getById: (id: string) => RTE<FooRepositoryAccess, Error, Foo>;
store: (foo: Foo) => RTE<FooRepositoryAccess, Error, void>;
};
export declare const makeFooRepository: () => Promise<FooRepository>;
Note:
- We follow the module pattern when defining the
FooRepositoryAccess
interface to enable context aggregation when composing multipleReaderTaskEither
:
declare const a: RTE<{ serviceA: ServiceA },never,void>
declare const b: RTE<{ serviceB: ServiceB },never,void>
const ab: RTE<{ serviceA: ServiceA; serviceB: ServiceB },never,void>
= rte.flatMap(a,() => b)
- We define a companion object
FooRepository
that exposes the same methods as the repository itself, except that they each require a context withFooRepositoryAccess
. This makes for more concise code later on:
const theLongWay: RTE<FooRepositoryAccess, Error, Foo> = pipe(
rte.ask<FooRepositoryAccess>(),
rte.flatMap(({ fooRepository }) => fooRepository.getById('id'))
);
const theEasyWay: RTE<FooRepositoryAccess, Error, Foo>
= FooRepository.getById('id')
We also define a service port to transform a Foo
:
// TransformFooService.ts
export interface TransformFooService {
transform: (foo: Foo) => RTE<unknown, Error, Foo>;
}
export interface TransformFooServiceAccess {
transformFooService: TransformFooService;
}
export declare const TransformFooService: {
transform: (foo: Foo) => RTE<TransformFooServiceAccess, Error, Foo>;
};
declare const makeTransformFooService: () => Promise<TransformFooService>;
Next we can write two use cases: one to create a new Foo
, and another one to transform a Foo
:
// usecases.ts
export const createFooUseCase = (id:string) =>
pipe(
rte.of(Foo.make(id)),
rte.tap(FooRepository.store)
);
export const transformFooUseCase = (id: string) =>
pipe(
FooRepository.getById(id),
rte.flatMap(TransformFooService.transform),
rte.flatMap(FooRepository.store)
);
Finally, we can write our main
that will create all the port adapters and invoke our use cases:
// index.ts
const main = async () => {
const fooRepository = await makeFooRepository();
const transformFooService = await makeTransformFooService();
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
await transformFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
};
main();
Create Effect proxies of the ports
This step consists in generating new companion objects FooRepository
and TransformFooService
for our ports that are exposing an Effect version of the member methods.
First we rename the companion objects, adding a Fpts
suffix:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶{̶
export declare const FooRepositoryFpts: {
getById: (id: string) => RTE<FooRepositoryAccess, Error, Foo>;
store: (foo: Foo) => RTE<FooRepositoryAccess, Error, void>;
};
// TransformFooService.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶{̶
export declare const TransformFooServiceFpts: {
transform: (foo: Foo) => RTE<TransformFooServiceAccess, Error, Foo>;
};
// usecases.ts
export const createFooUseCase = (id:string) =>
pipe(
rte.of(Foo.make(id)),
r̶t̶e̶.̶t̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶s̶t̶o̶r̶e̶)̶
rte.tap(FooRepositoryFpts.store)
);
export const transformFooUseCase = (id: string) =>
pipe(
F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶g̶e̶t̶B̶y̶I̶d̶(̶i̶d̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶.̶t̶r̶a̶n̶s̶f̶o̶r̶m̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶.̶s̶t̶o̶r̶e̶)̶
FooRepositoryFpts.getById(id),
rte.flatMap(TransformFooServiceFpts.transform),
rte.flatMap(FooRepositoryFpts.store)
);
Then we use the portToEffect
helper function to generate the Effect companion objects from the previous companion objects:
// FooRepository.ts
export const FooRepositoryTag = Context.GenericTag<FooRepository>(
"FooRepository"
);
export const FooRepository = portToEffect(FooRepositoryFpts, {
fooRepository: FooRepositoryTag,
}); // { getById: (id: string) => Effect<Foo, Error, FooRepository> ... }
// TransformFooService.ts
export const TransformFooServiceTag = Context.GenericTag<TransformFooService>(
"TransformFooService"
);
export const TransformFooService = portToEffect(TransformFooServiceFpts, {
transformFooService: TransformFooServiceTag,
}); // { transform: (foo: Foo) => Effect<Foo, Error, TransformFooService> }
Rewrite a use case in Effect
At this point we can start using our newly generated Effect companion objects to rewrite the transformFooUseCase
use case in Effect. Note that we voluntarily leave the createFooUseCase
use case as is to simulate a migration that is ongoing, as opposed to a “big-bang” migration where we would convert all of our use cases to Effect in one go (much harder and riskier).
// usecases.ts
export const transformFooUseCase = (id: string) =>
pipe(
F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶.̶g̶e̶t̶B̶y̶I̶d̶(̶i̶d̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶.̶t̶r̶a̶n̶s̶f̶o̶r̶m̶)̶,̶
r̶t̶e̶.̶f̶l̶a̶t̶M̶a̶p̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶.̶s̶t̶o̶r̶e̶)̶
FooRepository.getById(id),
Effect.flatMap(TransformFooService.transform),
Effect.flatMap(FooRepository.store)
); // Effect<void, Error, TransformFooService | FooRepository>
Since we don’t want to impact our main
program yet, we must maintain an fp-ts version of this use case, for backward compatibility. We can generate it from the Effect version thanks to the functionToFpts
helper function:
// usecases.ts
export const transformFooUseCaseFpts = functionToFpts(transformFooUseCase, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
}); // RTE<TransformFooServiceAccess & FooRepositoryAccess, Error, void>
// index.ts
const main = async () => {
const fooRepository = await makeFooRepository();
const transformFooService = await makeTransformFooService();
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
a̶w̶a̶i̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶U̶s̶e̶C̶a̶s̶e̶(̶"̶m̶y̶-̶f̶o̶o̶-̶i̶d̶"̶)̶(̶{̶
await transformFooUseCaseFpts("my-foo-id")({
transformFooService,
fooRepository,
})();
};
main();
Convert ports to Effect
Next we convert our FooRepository
port to Effect directly:
// FooRepository.ts
export interface FooRepository {
g̶e̶t̶B̶y̶I̶d̶:̶ ̶(̶i̶d̶:̶ ̶s̶t̶r̶i̶n̶g̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
s̶t̶o̶r̶e̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶v̶o̶i̶d̶>̶;̶
getById: (id: string) => Effect.Effect<Foo, Error>;
store: (foo: Foo) => Effect.Effect<void, Error>;
}
We can now generate the Effect companion object using Effect.serviceFunctions
:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶p̶o̶r̶t̶T̶o̶E̶f̶f̶e̶c̶t̶(̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶,̶ ̶{̶
̶ ̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶T̶a̶g̶,̶
̶}̶)̶;̶
export const FooRepository = Effect.serviceFunctions(FooRepositoryTag);
Finally, for backward compatibility, we must maintain the fp-ts companion object. We can generate it using the portToFpts
helper function:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶F̶p̶t̶s̶:̶ ̶{̶
̶ ̶ ̶g̶e̶t̶B̶y̶I̶d̶:̶ ̶(̶i̶d̶:̶ ̶s̶t̶r̶i̶n̶g̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
̶ ̶ ̶s̶t̶o̶r̶e̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶v̶o̶i̶d̶>̶;̶
̶}̶;̶
export const FooRepositoryFpts = portToFpts(FooRepository, {
fooRepository: FooRepositoryTag,
}); // { getById: (id: string) => RTE<FooRepositoryAccess, Error, Foo>; ... }
We do the same for the TransformFooService
port:
// TransformFooService.ts
export interface TransformFooService {
t̶r̶a̶n̶s̶f̶o̶r̶m̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶u̶n̶k̶n̶o̶w̶n̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
transform: (foo: Foo) => Effect<Foo, Error>;
}
e̶x̶p̶o̶r̶t̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶ ̶=̶ ̶p̶o̶r̶t̶T̶o̶E̶f̶f̶e̶c̶t̶(̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶,̶ ̶{̶
̶ ̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶T̶a̶g̶,̶
̶}̶)̶;̶
export const TransformFooService = Effect.serviceFunctions(
TransformFooServiceTag
);
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶F̶p̶t̶s̶:̶ ̶{̶
̶ ̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶:̶ ̶(̶f̶o̶o̶:̶ ̶F̶o̶o̶)̶ ̶=̶>̶ ̶R̶T̶E̶<̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶A̶c̶c̶e̶s̶s̶,̶ ̶E̶r̶r̶o̶r̶,̶ ̶F̶o̶o̶>̶;̶
̶}̶;̶
export const TransformFooServiceFpts = portToFpts(TransformFooService, {
fooRepository: FooRepositoryTag,
}); // { transform: (foo: Foo) => RTE<unknown, Error, Foo>; }
Note that we have not changed our main
in this step and it can still be run without a problem.
Use ManagedRuntime to run Effect usecases
In order to run the transformFooUseCase
as an Effect, we must be able to provide our ports via Layers:
// FooRepository.ts
e̶x̶p̶o̶r̶t̶ ̶d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶m̶a̶k̶e̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶(̶)̶ ̶=̶>̶ ̶P̶r̶o̶m̶i̶s̶e̶<̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶>̶;̶
export declare const FooRepositoryLive: Layer.Layer<FooRepository>;
// TransformFooService.ts
d̶e̶c̶l̶a̶r̶e̶ ̶c̶o̶n̶s̶t̶ ̶m̶a̶k̶e̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶(̶)̶ ̶=̶>̶ ̶P̶r̶o̶m̶i̶s̶e̶<̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶>̶;̶
declare const TransformFooServiceLive: Layer.Layer<TransformFooService>;
Next we can create a ManagedRuntime
and extract all the ports from the runtime context using the contextToFpts
helper:
// index.ts
const main = async () => {
c̶o̶n̶s̶t̶ ̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶ ̶=̶ ̶a̶w̶a̶i̶t̶ ̶m̶a̶k̶e̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶(̶)̶;̶
c̶o̶n̶s̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶ ̶=̶ ̶a̶w̶a̶i̶t̶ ̶m̶a̶k̶e̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶(̶)̶;̶
const runtime = ManagedRuntime.make(
Layer.mergeAll(FooRepositoryLive, TransformFooServiceLive)
);
const { context } = await runtime.runtime();
const { fooRepository, transformFooService } = contextToFpts(context, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
});
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
await transformFooUseCaseFpts("my-foo-id")({
transformFooService,
fooRepository,
})();
};
main();
Finally, we can use the runtime to run the Effect transformFooUseCase
:
// index.ts
const main = async () => {
const runtime = ManagedRuntime.make(
Layer.mergeAll(FooRepositoryLive, TransformFooServiceLive)
);
const { context } = await runtime.runtime();
const { fooRepository, transformFooService } = contextToFpts(context, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
});
await createFooUseCase("my-foo-id")({
transformFooService,
fooRepository,
})();
a̶w̶a̶i̶t̶ ̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶U̶s̶e̶C̶a̶s̶e̶F̶p̶t̶s̶(̶"̶m̶y̶-̶f̶o̶o̶-̶i̶d̶"̶)̶(̶{̶
̶t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶,̶
̶f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶,̶
}̶)̶(̶)̶;̶
await runtime.runPromise(transformFooUseCase("my-foo-id"));
};
main();
Note that, once again, we left the createFooUseCase
use case as is to show that we can be in a hybrid state where only part of the use cases have been migrated to Effect.
Bonus: simplify fp-ts ↔ effect tag mapping management
All of the helpers we have used throughout this migration require a mapping object to go from the key name of the fp-ts port Access interface (eg transformFooService
of TransformFooServiceAccess
) to the Tag
of the corresponding Effect port. For example:
contextToFpts(context, {
fooRepository: FooRepositoryTag,
transformFooService: TransformFooServiceTag,
});
This mapping is essential for all the helpers to work correctly. It is not ideal to have to craft them like that all the time. To help us with that, we introduce:
const FptsConvertibleId = Symbol();
interface FptsConvertible<T extends string> {
[FptsConvertibleId]: T;
}
We can now embed this conversion information at the type level of our ports:
// FooRepository.ts
export interface FooRepository extends FptsConvertible<"fooRepository"> {
getById: (id: string) => Effect.Effect<Foo, Error>;
store: (foo: Foo) => Effect.Effect<void, Error>;
}
// TransformFooService.ts
export interface TransformFooService
extends FptsConvertible<"transformFooService"> {
transform: (foo: Foo) => Effect<Foo, Error>;
}
The first thing we can do with this is to simplify the definition of Access interfaces using a type helper FptsAccess
:
// FooRepository.ts
export interface FooRepositoryAccess extends FptsAccess<FooRepository> {}
// TransformFooService.ts
export interface TransformFooServiceAccess
extends FptsAccess<TransformFooService> {}
And we can also define smaller atomic mapping objects using a new helper getFptsMapping
:
// FooRepository.ts
const FooRepositoryFptsMapping = getFptsMapping(
FooRepositoryTag,
"fooRepository"
); // { fooRepository: FooRepositoryTag }
// TransformFooService.ts
const TransformFooServiceFptsMapping = getFptsMapping(
TransformFooServiceTag,
"transformFooService"
); // { transformFooService: TransformFooServiceTag }
Note: It looks like we are once again typing the key "fooRepository"
or "transformFooService"
but in fact, the function getFptsMapping
is type-safe so that given FooRepositoryTag
as first argument, only the string "fooRepository"
is valid as second argument. So your code editor will autocomplete it for you. Moreover, the compiler will break if you change the definition in the FptsConvertible
so it is not really an additional burden.
We can now combine these two mapping objects when calling contextToFpts
or any other helper:
contextToFpts(context, {
f̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶:̶ ̶F̶o̶o̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶T̶a̶g̶,̶
t̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶:̶ ̶T̶r̶a̶n̶s̶f̶o̶r̶m̶F̶o̶o̶S̶e̶r̶v̶i̶c̶e̶T̶a̶g̶,̶
...FooRepositoryFptsMapping,
...TransformFooServiceFptsMapping,
});
Conclusion
Our objective of being able to write any new use case or port using Effect was accomplished in 2 months (working around 10% of our time on it)!
Teamwork was definitely a big part of this success: first, we have to mention Stephane Ledorze as he migrated all our repositories single-handedly and gave us great advice on how to define our migration strategy. We handled the rest with the whole team during dedicated “tech sessions” that we do every Wednesday afternoon at Inato: during those sessions, we stop delivering features to be able to focus on purely tech subjects, which was a great occasion to migrate the many ports we had to handle and onboard the team on Effect.
As we’re writing this article, we have around 150 full Effect use cases. The rest of the existing use cases will be migrated on the go whenever we need to update them!
We’re already seeing great improvements: for example, implementing rate limiting with just a few lines of code with Effect, whereas we needed a big amount of code to do it with fp-ts. We’re eager to leverage even more the Effect ecosystem now that we have officially migrated to it!
We hope this article motivated you to take the leap from fp-ts to Effect, don’t hesitate to comment if you have any questions or comments!
This article was written by Jeremie Dayan and Laure Retru-Chavastel
Top comments (0)