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:
- Write software free of many assumptions and therefore minimize the effort needed for acquisitions to integrate with the acquiring company's systems.
- Adopt Effect in an incremental manner.
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),
}
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
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
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
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
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
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
.