In certain scenarios, you might need to perform a sequence of chained operations where the success of each operation depends on the previous one. However, if any of the operations fail, you would want to reverse the effects of all previous successful operations. This pattern is valuable when you need to ensure that either all operations succeed, or none of them have any effect at all.
Effect offers a way to achieve this pattern using the Effect.acquireRelease function in combination with the Exit type.
The Effect.acquireRelease function allows you to acquire a resource, perform operations with it, and release the resource when you’re done.
The Exit type represents the outcome of an effectful computation, indicating whether it succeeded or failed.
Let’s go through an example of implementing this pattern. Suppose we want to create a “Workspace” in our application, which involves creating an S3 bucket, an ElasticSearch index, and a Database entry that relies on the previous two.
To begin, we define the domain model for the required services :
S3
ElasticSearch
Database
import { Effect , Context } from "effect"
readonly _tag = "S3Error"
class S3 extends Context . Tag ( "S3" )<
readonly createBucket : Effect . Effect < Bucket , S3Error >
readonly deleteBucket : ( bucket : Bucket ) => Effect . Effect < void >
class ElasticSearchError {
readonly _tag = "ElasticSearchError"
class ElasticSearch extends Context . Tag ( "ElasticSearch" )<
readonly createIndex : Effect . Effect < Index , ElasticSearchError >
readonly deleteIndex : ( index : Index ) => Effect . Effect < void >
readonly _tag = "DatabaseError"
class Database extends Context . Tag ( "Database" )<
) => Effect . Effect < Entry , DatabaseError >
readonly deleteEntry : ( entry : Entry ) => Effect . Effect < void >
Next, we define the three create actions and the overall transaction (make
) for the
import { Effect , Context , Exit } from "effect"
readonly _tag = "S3Error"
class S3 extends Context . Tag ( "S3" )<
readonly createBucket : Effect . Effect < Bucket , S3Error >
readonly deleteBucket : ( bucket : Bucket ) => Effect . Effect < void >
class ElasticSearchError {
readonly _tag = "ElasticSearchError"
class ElasticSearch extends Context . Tag ( "ElasticSearch" )<
readonly createIndex : Effect . Effect < Index , ElasticSearchError >
readonly deleteIndex : ( index : Index ) => Effect . Effect < void >
readonly _tag = "DatabaseError"
class Database extends Context . Tag ( "Database" )<
) => Effect . Effect < Entry , DatabaseError >
readonly deleteEntry : ( entry : Entry ) => Effect . Effect < void >
// Create a bucket, and define the release function that deletes the
// bucket if the operation fails.
const createBucket = Effect . gen ( function* () {
const { createBucket , deleteBucket } = yield* S3
return yield* Effect . acquireRelease ( createBucket , ( bucket , exit ) =>
// The release function for the Effect.acquireRelease operation is
// responsible for handling the acquired resource (bucket) after the
// main effect has completed. It is called regardless of whether the
// main effect succeeded or failed. If the main effect failed,
// Exit.isFailure(exit) will be true, and the function will perform
// a rollback by calling deleteBucket(bucket). If the main effect
// succeeded, Exit.isFailure(exit) will be false, and the function
// will return Effect.void, representing a successful, but
Exit . isFailure ( exit ) ? deleteBucket ( bucket ) : Effect . void
// Create an index, and define the release function that deletes the
// index if the operation fails.
const createIndex = Effect . gen ( function* () {
const { createIndex , deleteIndex } = yield* ElasticSearch
return yield* Effect . acquireRelease ( createIndex , ( index , exit ) =>
Exit . isFailure ( exit ) ? deleteIndex ( index ) : Effect . void
// Create an entry in the database, and define the release function that
// deletes the entry if the operation fails.
const createEntry = ( bucket : Bucket , index : Index ) =>
Effect . gen ( function* () {
const { createEntry , deleteEntry } = yield* Database
return yield* Effect . acquireRelease (
createEntry ( bucket , index ),
Exit . isFailure ( exit ) ? deleteEntry ( entry ) : Effect . void
const make = Effect . scoped (
Effect . gen ( function* () {
const bucket = yield* createBucket
const index = yield* createIndex
return yield* createEntry ( bucket , index )
We then create simple service implementations to test the behavior of our Workspace code.
To achieve this, we will utilize layers to construct test
These layers will be able to handle various scenarios, including errors, which we can control using the FailureCase
type.
import { Effect , Context , Layer , Console , Exit } from "effect"
readonly _tag = "S3Error"
class S3 extends Context . Tag ( "S3" )<
readonly createBucket : Effect . Effect < Bucket , S3Error >
readonly deleteBucket : ( bucket : Bucket ) => Effect . Effect < void >
class ElasticSearchError {
readonly _tag = "ElasticSearchError"
class ElasticSearch extends Context . Tag ( "ElasticSearch" )<
readonly createIndex : Effect . Effect < Index , ElasticSearchError >
readonly deleteIndex : ( index : Index ) => Effect . Effect < void >
readonly _tag = "DatabaseError"
class Database extends Context . Tag ( "Database" )<
) => Effect . Effect < Entry , DatabaseError >
readonly deleteEntry : ( entry : Entry ) => Effect . Effect < void >
// Create a bucket, and define the release function that deletes the
// bucket if the operation fails.
const createBucket = Effect . gen ( function* () {
const { createBucket , deleteBucket } = yield* S3
return yield* Effect . acquireRelease ( createBucket , ( bucket , exit ) =>
// The release function for the Effect.acquireRelease operation is
// responsible for handling the acquired resource (bucket) after the
// main effect has completed. It is called regardless of whether the
// main effect succeeded or failed. If the main effect failed,
// Exit.isFailure(exit) will be true, and the function will perform
// a rollback by calling deleteBucket(bucket). If the main effect
// succeeded, Exit.isFailure(exit) will be false, and the function
// will return Effect.void, representing a successful, but
Exit . isFailure ( exit ) ? deleteBucket ( bucket ) : Effect . void
// Create an index, and define the release function that deletes the
// index if the operation fails.
const createIndex = Effect . gen ( function* () {
const { createIndex , deleteIndex } = yield* ElasticSearch
return yield* Effect . acquireRelease ( createIndex , ( index , exit ) =>
Exit . isFailure ( exit ) ? deleteIndex ( index ) : Effect . void
// Create an entry in the database, and define the release function that
// deletes the entry if the operation fails.
const createEntry = ( bucket : Bucket , index : Index ) =>
Effect . gen ( function* () {
const { createEntry , deleteEntry } = yield* Database
return yield* Effect . acquireRelease (
createEntry ( bucket , index ),
Exit . isFailure ( exit ) ? deleteEntry ( entry ) : Effect . void
const make = Effect . scoped (
Effect . gen ( function* () {
const bucket = yield* createBucket
const index = yield* createIndex
return yield* createEntry ( bucket , index )
// The `FailureCaseLiterals` type allows us to provide different error
// scenarios while testing our
// For example, by providing the value "S3", we can simulate an error
// scenario specific to the S3 service. This helps us ensure that our
// program handles errors correctly and behaves as expected in various
// Similarly, we can provide other values like "ElasticSearch" or
// "Database" to simulate error scenarios for those In cases
// where we want to test the absence of errors, we can provide
// `undefined`. By using this parameter, we can thoroughly test our
// services and verify their behavior under different error conditions.
type FailureCaseLiterals = "S3" | "ElasticSearch" | "Database" | undefined
class FailureCase extends Context . Tag ( "FailureCase" )<
// Create a test layer for the S3 service
const S3Test = Layer . effect (
Effect . gen ( function* () {
const failureCase = yield* FailureCase
createBucket : Effect . gen ( function* () {
console . log ( "[S3] creating bucket" )
if ( failureCase === "S3" ) {
return yield* Effect . fail ( new S3Error ())
return { name : "<bucket.name>" }
deleteBucket : ( bucket ) =>
Console . log ( `[S3] delete bucket ${ bucket . name }` )
// Create a test layer for the ElasticSearch service
const ElasticSearchTest = Layer . effect (
Effect . gen ( function* () {
const failureCase = yield* FailureCase
createIndex : Effect . gen ( function* () {
console . log ( "[ElasticSearch] creating index" )
if ( failureCase === "ElasticSearch" ) {
return yield* Effect . fail ( new ElasticSearchError ())
return { id : "<index.id>" }
Console . log ( `[ElasticSearch] delete index ${ index . id }` )
// Create a test layer for the Database service
const DatabaseTest = Layer . effect (
Effect . gen ( function* () {
const failureCase = yield* FailureCase
createEntry : ( bucket , index ) =>
Effect . gen ( function* () {
"[Database] creating entry for bucket" +
`${ bucket . name } and index ${ index . id }`
if ( failureCase === "Database" ) {
return yield* Effect . fail ( new DatabaseError ())
return { id : "<entry.id>" }
Console . log ( `[Database] delete entry ${ entry . id }` )
// Merge all the test layers for S3, ElasticSearch, and Database
// services into a single layer
const layer = Layer . mergeAll ( S3Test , ElasticSearchTest , DatabaseTest )
// Create a runnable effect to test the Workspace code. The effect is
// provided with the test layer and a FailureCase service with undefined
// value (no failure case).
const runnable = make . pipe (
Effect . provideService ( FailureCase , undefined )
Effect . runPromise ( Effect . either ( runnable )). then ( console . log )
Let’s examine the test results for the scenario where FailureCase
is set to undefined
(happy path):
[ElasticSearch] creating index
[Database] creating entry for bucket <bucket.name> and index <index.id>
In this case, all operations succeed, and we see a successful result with right({ id: '<entry.id>' })
.
Now, let’s simulate a failure in the Database
:
const runnable = make. pipe (
Effect. provideService (FailureCase, "Database" )
The console output will be:
[ElasticSearch] creating index
[Database] creating entry for bucket <bucket.name> and index <index.id>
[ElasticSearch] delete index <index.id>
[S3] delete bucket <bucket.name>
You can observe that once the Database
error occurs, there is a complete rollback that deletes the ElasticSearch
index first and then the associated S3
bucket. The result is a failure with left(new DatabaseError())
.
Let’s now make the index creation fail instead:
const runnable = make. pipe (
Effect. provideService (FailureCase, "ElasticSearch" )
In this case, the console output will be:
[ElasticSearch] creating index
[S3] delete bucket <bucket.name>
_tag: "ElasticSearchError"
As expected, once the ElasticSearch
index creation fails, there is a rollback that deletes the S3
bucket. The result is a failure with left(new ElasticSearchError())
.