Skip to content

Instantly share code, notes, and snippets.

@vecerek
Last active February 10, 2025 19:52
Show Gist options
  • Save vecerek/cb44e8edc6309cb840ae8550dbe6f5ce to your computer and use it in GitHub Desktop.
Save vecerek/cb44e8edc6309cb840ae8550dbe6f5ce to your computer and use it in GitHub Desktop.
Enterprise Design Patterns for Effect

Motivation and Objectives

Enterprise software often has more non-functional requirements to fulfill because of the nature of the business of large companies. Think about it! Large companies often buy smaller ones. When that happens, some integration work of the two companies is imminent. Very often the case is that there is a mismatch between the acquiring and the acquired company's software stack. For example:

  • cloud provider and/or other software hosting solutions
  • tech stack
  • tech standards (secrets injected into the FS vs. read from the network at boot time)
  • etc.

To build robust and flexible systems, the designers and architects have to minimize the number of assumptions they make about the caller/integrator/user. In this gist, I want to demonstrate some conventions and design patterns using Effect that can be utilized to:

  1. Write software free of many assumptions and therefore minimize the effort needed for acquisitions to integrate with the acquiring company's systems.
  2. Adopt Effect in an incremental manner.

Design Patterns

Services

In the context of this gist, a "service" simply means an object that is a logically grouped set of properties and functions. For example:

type Service = {
  make: Effect.Effect<ULID, UlidError>
}

The type Service represent a factory that can generate such Ulid values using its make property. This property is an Effect because generating such an ID can fail. For the sake of brevity, we simply assume the existence of a branded ULID type and an error type representing the failure case.

The service in its current form cannot easily be adopted without forcing the integrator to also learn, install, and use Effect. To allow e.g. legacy codebases to still use this service, we should also expose non-Effect APIs.

type Service = {
  make: Effect.Effect<ULID, UlidError>
  makeSync: () => ULID
}

We can establish the convention that any property of a service that is an Effect or a function returning an Effect has to be accompanied with a similar, non-Effect, property. For synchronous operations, such properties should be suffixed with Sync and asynchronous operations should be suffixed with Promise.

Every *Sync and *Promise property can be implemented using the original property. To do so, let's first implement some helper functions, namely syncify and promisify:

import { Cause, Effect, Exit, flow } from "effect"

const handleExit = <A, E>(exit: Exit.Exit<A, E>) => {
  if (Exit.isSuccess(exit)) {
    return exit.value
  }

  throw Cause.squash(exit.cause)
}

export const syncify = flow(Effect.runSyncExit, handleExit)
export const promisify = flow(Effect.runPromiseExit, (promise) =>
  promise.then(handleExit),
)

These helper functions take an Effect, and make sure that any of the possible errors are unwrapped in their original form. For example, in case of our Ulid service, integrators could simply check if the error is of an instance of UlidError like so:

try {
  const id = ulid.makeSync()
  
  // The rest of the program
} catch (e) {
  if (e instanceof UlidError) {
    // Handle error
  }
}

Without "squashing" the cause, Effect would wrap these errors and the instanceof checks would not work. The full implementation of the Ulid service, using our helper functions would look like so:

// hiding the implementation of a Ulid generator for the sake of brevity
declare const generate: Effect.Effect<Ulid, UlidError>

const service: Service {
  make: generate,
  makeSync: () => syncify(generate),
}

Service constructors a.k.a Layers

When it comes to the instantiation of services, Effect came up with the concept of Layers. Let's discover the conventions and patterns we can use to abstract away these Layers from the integrators.

export namespace Ulid {
  export interface Config {
    seedTime?: number
  }
  
  export type Service = {
    make: Effect.Effect<ULID, UlidError>
    makeSync: () => ULID
  }
}

export class Ulid extends Context.Tag("ulid")<Ulid, Ulid.Service>() {
  /**
   * A function that takes a config object and returns an instance of a ulid generator.
   * The config takes an optional `seedTime` property. The seed time **does not** ensure
   * that the generated ULIDs are always the same. It's merely useful for migrating to
   * ulids from other 128-bit id formats. For more details check:
   * https://github.com/ulid/javascript?tab=readme-ov-file#seed-time
   */
  static readonly make = (config: Ulid.Config) => {
    const generate = internal.make(config.seedTime)

    return {
      make: generate,
      makeSync: () => Effect.runSync(generate),
    }
  }

  /**
   * A function that takes a wrapped `Ulid.Config` object and returns
   * a layer of a `Ulid`.
   */
  static readonly layer = (config: Config.Config.Wrap<Ulid.Config>) =>
    Config.unwrap(config).pipe(Effect.map(this.make), Layer.effect(this))
    
  /**
   * The default Ulid generator configuration object wrapped as an Effect Config.
   * It will make sure that the service generates a new ULID every time `make` or
   * `makeSync` is invoked.
   */
  static readonly defaultConfig = {
    seedTime: Config.number("ULID_SEED_TIME").pipe(
      Config.orElse(() => Config.succeed(undefined))
    ),
  }

  /**
   * The default implementation of the Ulid layer.
   */
  static readonly live = this.layer(this.defaultConfig)
  
  static readonly loadDefaultConfig = () =>
    Config.unwrap(this.defaultConfig).pipe(
      promisify,
    )
}

The above Ulid tag implements a few readonly static methods, each fit for a specific use case.

make

make is a pure function that takes a configuration object and return an instance of the Ulid service. This function is our basic building block. At the same time, it allows legacy codebases adopting this service without the need to use Effect.

layer

layer is a function that takes an Effect Config object and returns a Layer of a Ulid service. This function is useful for creating Ulid service layers with various Config definitions. For example, if the integrator cannot use the provided default configuration.

defaultConfig

defaultConfig describes the default configuration. This is the place where one can implement company-specific standards. For example (although a very silly one), the standard could say that the ULID_SEED_TIME environment variable has to be used for the purpose of configuring all ulid generators.

Imagine, that the business acquires a new company who have a similar standard but they use a different environment variable. Instead of being blocked on the environment variable change, requiring a migration, they could simply implement their own Config object and build a Ulid layer that way, or directly use Ulid.make, until the migration is prioritized, after which they can simply switch over to using Ulid.defaultConfig or Ulid.live.

live

live is the final and complete form of the Ulid layer. This is basically the application of the default configuration to Ulid.layer. In our case, the Ulid layer does not depend on any other layer. If that were the case, by convention, all the requirements of the final layer should also be satisfied. The main idea behind live is to provide the easiest path to adoption. Whereas layer, is something that would be more suitable for internal testing where all requirements could easily be stubbed.

loadDefaultConfig

loadDefaultConfig is a thunk returning a Promise of the default configuration object. This is meant to be used in non-Effect projects following the company standards in combination with Ulid.make.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment