Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: service anatomy #38

Merged
merged 10 commits into from
Sep 27, 2023
272 changes: 272 additions & 0 deletions docs/docs/anatomy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
---
sidebar_position: 3
---

# Anatomy of an Equinox Service

An Equinox service is composed of a few modules (namespaces).

- **Stream**: shows which category the service writes to and how the identity
of the stream is composed (and the reverse operations for when running Reaction logic)
- **Events**: shows which events the service writes
- **Fold**: shows how the events are folded into state
- **Decide**: shows which actions can be taken upon the state (resulting in
new events being written)
- **Service**: the class that wraps the above into a cohesive domain service.
nordfjord marked this conversation as resolved.
Show resolved Hide resolved
- **Config**: shows how we've opted to to bind the Service to Streams in a Concrete Store (which Access Strategies to apply, if any)
including important information like access strategies.

:::tip

Everything above `Service` is considered internal to the module. It is exported
as a conveninence for testing, but other modules should not take a dependency on
nordfjord marked this conversation as resolved.
Show resolved Hide resolved
anything other than the `Service`.

:::


Let's see what each of these modules looks like for a simple service for
checking in and out for an Appointment Booking.



## The Stream

```ts
export namespace Stream {
export const category = "AppointmentActuals"
export const streamId = StreamId.gen(AppointmentId.toString, UserId.toString)
export const decodeId = StreamId.dec(AppointmentId.parse, UserId.parse)
export const tryMatch = StreamName.tryMatch(category, decodeId)
}
```

## The Events

```ts
export namespace Events {
// prettier-ignore
const date = z.string().datetime().transform((x) => new Date(x))
export const Timestamp = z.object({ timestamp: date })
export const ActualsOverridden = z.object({ checkedIn: date, checkedOut: date })
export type ActualsOverridden = z.infer<typeof ActualsOverridden>

export type Event =
| { type: "CheckedIn"; data: Timestamp }
| { type: "CheckedOut"; data: Timestamp }
| { type: "ActualsOverridden"; data: ActualsOverridden }

export const codec = Codec.upcast<Event>(
Codec.json(),
Codec.Upcast.from({
CheckedIn: Timstamp.parse,
CheckedOut: Timstamp.parse,
ActualsOverridden: ActualsOverridden.parse,
}),
)
}
```

## The Fold

```ts
export namespace Fold {
import Event = Events.Event
export type State = { checkedIn?: Date; checkedOut?: Date }
export const initial: State = {}
export const evolve = (state: State, event: Event) => {
switch (event.type) {
case "CheckedIn":
return { ...state, checkedIn: event.data.timestamp }
case "CheckedOut":
return { ...state, checkedOut: event.data.timestamp }
case "ActualsOverridden":
return event.data
}
}
}
```

## The Decide
bartelink marked this conversation as resolved.
Show resolved Hide resolved

```ts
export namespace Decide {
import Event = Events.Event
import State = Fold.State
export const checkIn =
(timestamp: Date) =>
(state: State): Event[] => {
// Check if already checked in with a different timestamp
// The caller is expected to guard against this
if (state.checkedIn && +state.checkedIn !== +timestamp) {
throw new Error("Already checked in with different timestamp")
}

// Silent idempotent handling: We accept retried requests with identical timestamps.
// If checked in with the same timestamp, no changes are required.
if (state.checkedIn && +state.checkedIn === +timestamp) return []

return [{ type: "CheckedIn", data: { timestamp } }]
}
export const checkOut =
(timestamp: Date) =>
(state: State): Event[] => {
if (state.checkedOut && +state.checkedOut !== +timestamp) {
throw new Error("Already checked out with different timestamp")
}
if (state.checkedOut && +state.checkedOut === +timestamp) return []
return [{ type: "CheckedOut", data: { timestamp } }]
}

export const manuallyOverride = (checkedIn: Date, checkedOut: Date) => (state: State) => {
if (+checkedIn === +state?.checkedIn && +checkedOut === +state?.checkedIn) return []
bartelink marked this conversation as resolved.
Show resolved Hide resolved
return [{ type: "ActualsOverridden", data: { checkedIn, checkedOut } }]
}
}
```

## The Service

```ts
export class Service {
constructor(
private readonly resolve: (
appointmentId: AppointmentId,
userId: UserId,
) => Decider<Events.Event, Fold.State>,
) {}

checkIn(appointmentId: AppointmentId, userId: UserId, timestamp: Date) {
const decider = this.resolve(appointmentId, userId)
return decider.transact(Decide.checkIn(timestamp), LoadOption.AssumeEmpty)
}

checkOut(appointmentId: AppointmentId, userId: UserId, timestamp: Date) {
const decider = this.resolve(appointmentId, userId)
return decider.transact(Decide.checkOut(timestamp))
}

manuallyOverride(
appointmentId: AppointmentId,
userId: UserId,
checkedIn: Date,
checkedOut: Date,
) {
const decider = this.resolve(appointmentId, userId)
return decider.transact(Decide.manuallyOverride(checkedIn, checkedOut))
}
}
```

## The Config

<details>
<summary>See the `../equinox-config.ts` snippet</summary>

Feel free to copy-paste this snippet for your own project.
nordfjord marked this conversation as resolved.
Show resolved Hide resolved

```ts
import { ICodec, ICache, CachingStrategy, Codec } from "@equinox-js/core"
import { MemoryStoreCategory, VolatileStore } from "@equinox-js/memory-store"
import * as MessageDB from "@equinox-js/message-db"
import * as DynamoDB from "@equinox-js/dynamo-store"

export enum Store {
Memory,
MessageDb,
Dynamo,
}

export type Config =
| { store: Store.Memory; context: VolatileStore<string> }
| { store: Store.MessageDb; context: MessageDB.MessageDbContext; cache: ICache }
| { store: Store.Dynamo; context: DynamoDB.DynamoStoreContext; cache: ICache }

// prettier-ignore
export namespace MessageDb {
import AccessStrategy = MessageDB.AccessStrategy
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm should AccessStrategy be prefixed too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as in MessageDBAccessStrategy.Unoptimized()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather the other stuff lost its prefix

DynamoStore.DynamoStoreCategory.create(....)
// vs
DynamoStore.Category.create(...)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, but some names are very generic (client, category, context)
and then there are other conventional names like Config, Store, Streams also in the same scope
Given the names should only really be used in the context of one of those Config binding wiring blocks, the fact they are a bit long is no real harm
Also Category would clash with Equinox.Category (and I wanted to rename ISyncContext to Context but again same concerns stop me esp given how overloaded the term Context is)

A lot of these things get close enough to files that have domain logic too - things like Category etc can easily encroach on and/or require constant disambiguation.

Can't see where I saw the argument for the long names - it was in some Azure Core guidlines or sometihng (not they should be conodered arbiters of taste for even 5 mins!)

import Category = MessageDB.MessageDbCategory
import Context = MessageDB.MessageDbContext
type Config = { context: Context; cache: ICache }

export function createCached<E, S, C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, access: AccessStrategy<E, S>, { context, cache }: Config) {
const caching = CachingStrategy.Cache(cache)
return Category.create(context, name, codec, fold, initial, caching, access);
}
export function createUnoptimized<E, S, C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, config: Config) {
const access = AccessStrategy.Unoptimized<E, S>()
return MessageDb.createCached(name, codec, fold, initial, access, config)
}
export function createSnapshotted<E, S, C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, eventName: string, toSnapshot: (s: S) => E, config: Config) {
const access = AccessStrategy.AdjacentSnapshots(eventName, toSnapshot)
return MessageDb.createCached(name, codec, fold, initial, access, config)
}
export function createLatestKnown<E, S, C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, config: Config) {
nordfjord marked this conversation as resolved.
Show resolved Hide resolved
const access = AccessStrategy.LatestKnownEvent<E, S>()
return MessageDb.createCached(name, codec, fold, initial, access, config)
}
}

// prettier-ignore
export namespace Dynamo {
import AccessStrategy = DynamoDB.AccessStrategy
import Category = DynamoDB.DynamoStoreCategory
import Context = DynamoDB.DynamoStoreContext
type Config = { context: Context; cache: ICache }
export function createCached<E, S, C>(name: string, codec_: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, access: AccessStrategy<E, S>, { context, cache }: Config) {
const caching = CachingStrategy.Cache(cache)
const codec = Codec.compress(codec_)
return Category.create(context, name, codec, fold, initial, caching, access);
}
export function createUnoptimized<E, S, C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, config: Config) {
const access = AccessStrategy.Unoptimized()
return Dynamo.createCached(name, codec, fold, initial, access, config)
}
export function createLatestKnown<E,S,C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, config: Config) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is last in mdb - prob last here too as pretty rare

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, definitely more common to use snapshots than latest event in dynamo. In MDB I've got more services on LatestKnownEvent than AdjacentSnapshots.

const access = AccessStrategy.LatestKnownEvent()
return Dynamo.createCached(name, codec, fold, initial, access, config)
}
export function createSnapshotted<E,S,C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, isOrigin: (e: E) => boolean, toSnapshot: (s: S) => E, config: Config) {
const access = AccessStrategy.Snapshot(isOrigin, toSnapshot)
return Dynamo.createCached(name, codec, fold, initial, access, config)
}
export function createRollingState<E,S,C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, toSnapshot: (s: S) => E, config: Config) {
const access = AccessStrategy.RollingState(toSnapshot)
return Dynamo.createCached(name, codec, fold, initial, access, config)
}
}

// prettier-ignore
export namespace MemoryStore {
export function create<E, S, C>(name: string, codec: ICodec<E, string, C>, fold: (s: S, e: E[]) => S, initial: S, { context: store }: { context: VolatileStore<string> }) {
return MemoryStoreCategory.create(store, name, codec, fold, initial)
}
}
```

</details>

```ts
import * as Config from "../equinox-config"
class Service {
// ... as above

// prettier-ignore
static resolveCategory(config: Config.Config) {
switch (config.store) {
case Config.Store.Memory:
return Config.MemoryStore.create(Stream.category, Events.codec, Fold.fold, Fold.initial, config)
nordfjord marked this conversation as resolved.
Show resolved Hide resolved
case Config.Store.MessageDB:
return Config.MessageDB.createUnoptimized(Stream.category, Events.codec, Fold.fold, Fold.initial, config)
case Config.Store.Dynamo:
return Config.Dynamo.createUnoptimized(Stream.category, Events.codec, Fold.fold, Fold.initial, config)
}
}
static create(config: Config.Config) {
const category = Service.resolveCategory(config)
const resolve = (appointmentId: AppointmentId, userId: UserId) =>
Decider.forStream(category, Stream.streamId(appointmentId, userId), null)
nordfjord marked this conversation as resolved.
Show resolved Hide resolved
return new Service(resolve)
}
}
```