0.0.9-unstable • Published 4 months ago

dc-ts v0.0.9-unstable

Weekly downloads
5
License
ISC
Repository
-
Last release
4 months ago

dc-ts (Domain-Centric TypeScript)

dc-ts is a functional-oriented library designed to help developers build event-driven domain layers in TypeScript. It provides a structured approach to implementing domain logic, handling commands, emitting events, and managing state transitions in a type-safe and functional manner.


Architectural Principles

The domain layer is composed of different inner layers, each handling specific concerns:

  • Core: Implements the business logic without side effects. It contains type definitions for commands, events, state, and entities, and implements workflows and policies as pure functions.
  • Shell: Acts as a mediator between the application and domain layers, handling asynchronous code and side effects.
  • Views: Builds materialized views by listening to emitted events and makes them available through a query API.

Result Type

A fundamental aspect of functional programming is the Result type, which represents either a success or a failure. This allows you to maintain control over function outputs and chain functions together. In dc-ts, the result type is defined as follows:

// For synchronous functions:
export type Result<S, F extends string> = Success<S> | Failure<F>

// For asynchronous functions:
export type AsyncResult<S, F extends string> = Promise<Result<S, F>>

// Where:
export type Success<S> = { outcome: 'success', data: S }
export type Failure<F extends string> = { outcome: 'failure', cause: Cause<F>[] }
export type Cause<F extends string> = { msg: F, data?: any }

You can use the succeed or fail utility functions to return a success or failure. For example:

import { Result, succeed, fail } from 'dc-ts'

type Divide = 
  (num: number) => 
    (den: number) => 
      Result<number, 'cannot_divide_by_zero'>

const divideBy: Divide = 
  (num: number) => 
    (den: number) => {
      if (den === 0) {
        return fail('cannot_divide_by_zero')
      }
      return succeed(num / den)
    }

You can handle the result using the isFailure and isSuccess utilities:

import { isFailure } from 'dc-ts'

const divideRes = divideBy(6)(2)
if (isFailure(divideRes)) {
  return divideRes // { outcome: 'failure', cause: { msg: 'cannot_divide_by_zero' } }
}
// { outcome: 'success', data: 3 }
return succeed(divideRes.data + 1)

Parsing

In functional programming, parsing is used to validate input data before processing it. dc-ts provides a prebuilt SafeParse function signature:

export type SafeParse<T> = (data: T) => Result<T, 'parse_error'>

Since TypeScript types are not available at runtime, you can use libraries like Zod or TypeBox for runtime validation. dc-ts includes a prebuilt implementation of SafeParse using TypeBox:

import { safeParseTBox, Result } from 'dc-ts'
import { ToDoSchema } from './schema'

const parseToDo = safeParseTBox(ToDoSchema)

const parseRes: Result<ToDo, 'parse_error'> = parseToDo(
  { desc: 'Write readme file for dc-ts', status: 'in-progress' }
)

Messages

Messages in dc-ts are categorized into Commands and Events. Commands represent actions to be performed, while Events represent state changes that have occurred.

Commands

To define a command, use the CMD type:

import { CMD } from 'dc-ts'

type MoveToDoneData = { toDoId: string }
export type MoveToDoneCmd = CMD<'move-to-done', MoveToDoneData>

This produces the following type:

type MoveToDoneCmd = {
  id: string
  msgType: 'cmd'
  type: 'move-to-done'
  correlationid: string
  causationid: string
  timestamp: number
  data: {
    toDoId: string
  }
}

Events

To define an event, use the EVT type:

import { EVT } from 'dc-ts'

type ToDoDoneData = { toDoId: string, status: 'done' }
export type ToDoDoneEvt = EVT<'to-do-done', ToDoDoneData>

This produces the following type:

type ToDoDoneEvt = {
  id: string
  msgType: 'evt'
  type: 'to-do-done'
  correlationid: string
  causationid: string
  timestamp: number
  data: {
    toDoId: string
    status: 'done'
  }
}

Smart Constructors

It’s a good practice to aggregate all commands and events in your module into union types. This allows you to define smart constructors that guide you in creating valid commands and events:

import { newCmd, newEvt, safeParseTBox } from 'dc-ts'
import { ToDoCmd, ToDoEvt } from './schema'

export const newToDoCMD = <C extends ToDoCmd>() => {
  return newCmd<C['type'], C['data']>(safeParseTBox(ToDoCmd))
}

export const newToDoEVT = <E extends ToDoEvt>() => {
  return newEvt<E['type'], E['data']>(safeParseTBox(ToDoEvt))
}

These constructors ensure type safety and data validation:

const cmdRes = newToDoCMD<MoveToDoneCmd>()
  ('move-to-done') // Autocompletes to the right command type
  ({ toDoId: 'abc' }) // Suggests the correct data structure
  ({ correlationid: 'abc', causationid: 'efg' }) // Requires a domain trace

// cmdRes will be a Result<MoveToDoneCmd, 'parse_error'>

Domain Trace

Each message includes a domain trace to enable tracing the chain of commands and events. You can use the dtFromMsg utility to build a valid domain trace:

const dt: DomainTrace = dtFromMsg(createToDoCmd)
// { correlationid: 'same-as-cmd', causationid: 'cmd-id' }

const evtRes = newToDoEVT<ToDoCreatedEvt>()
  ('to-do-created') // Autocompletes
  ({ desc: 'Write dc-ts readme', status: 'created' }) // Guides data input
  (dt) // Valid domain trace

Core Workflows

Core workflows are pure functions that take a command and a state as inputs and return either a success containing an event or a failure. These workflows follow the sequence:

🟦 Command → 🟨 State → 🟧 Event

Defining Core Workflows

To define a core workflow, use the CoreWf type:

export type MoveToDoneWf = CoreWf<MoveToDoneCmd, MoveToDoneState, ToDoDoneEvt>

This provides a collection of types that you can access through the properties of the workflow type. For example:

const cmd: MoveToDoneWf['cmd'] // MoveToDoneCmd
const fn: MoveToDoneWf['fn'] // (c: MoveToDoneCmd) => (s: MoveToDoneState) => Result<ToDoDoneEvt, CoreWfFails>

Modelling the State

The state object typically includes:

  • An entity in a valid state
  • Additional information needed to process the command

For example:

export type MoveToDoneState = {
  toDo: InProgressToDo // Entity in a valid state
  isOfficeHour: boolean // Additional information
}

Implementing Workflows

Core workflows involve several steps: 1. Parse the input: Validate the data schema. 2. Check for invariants: Ensure the data makes sense. 3. Apply constraints: Enforce business rules. 4. Apply the state transition: Transform the entity. 5. Create and return the event: Or a failure if processing fails.

Parsing the Input

Parsing ensures the input data matches the expected schema. Here’s an example of parsing the state:

import { safeParseTBox } from 'dc-ts'
import { MoveToDoneState } from './schema'

const _parseState: MoveToDoneWf['parseState'] = safeParseTBox(MoveToDoneState)

Checking for Invariants

Invariants ensure the data is logically consistent. For example:

import { fail, succeed } from 'dc-ts'

const _invariants: MoveToDoneWf['invariants'] = 
  (cmd: MoveToDoneWf['cmd']) => 
    (state: MoveToDoneWf['state']) => {
      // Ensure the ToDo ID in the command matches the state
      if (cmd.data.toDoId !== state.toDo.id) {
        return fail('ids_dont_match')
      }
      return succeed(state)
    }

Applying Constraints

Constraints enforce business rules. For example, ensuring a ToDo can only be completed during office hours:

import { fail, succeed } from 'dc-ts'

const _officeHourCons: MoveToDoneWf['constrain'] = 
  (cmd: MoveToDoneWf['cmd']) => 
    (state: MoveToDoneWf['state']) => {
      if (!state.isOfficeHour) {
        return fail('can_only_complete_during_office_hours')
      }
      return succeed(state)
    }

Applying the State Transition

The state transition transforms the entity and creates the resulting event. For example:

import { DomainTrace, dtFromMsg } from 'dc-ts'

const _transition: MoveToDoneWf['transition'] = 
  (cmd: MoveToDoneWf['cmd']) => 
    (state: MoveToDoneWf['state']) => {
      // Build the new completed ToDo
      const toDo: CompletedToDo = {
        ...state.toDo,
        status: 'completed',
        completedAt: +Date.now()
      }

      // Generate a valid domain trace from the command
      const dt: DomainTrace = dtFromMsg(cmd)

      // Create the to-do-done event
      const evtRes = newToDoEVT<ToDoDoneEvt>()
        ('to-do-done')
        ({ toDoId: toDo.id, status: 'done' })
        (dt)

      // Return the event or the eventual parse failure
      return evtRes
    }

Composing the Workflow

Once the individual steps are defined, you can compose them into a workflow using the composeWf utility:

import { composeWf } from 'dc-ts'

export const moveToDoneWf: MoveToDoneWf['fn'] = 
  composeWf<MoveToDoneWf>(_parseState)
    (_invariants)
    ([_officeHourCons])
    (_transition)

This composed function can be imported and used by the Shell layer, ensuring it adheres to the defined business logic.


That's a fantastic idea! Let's build a more meaningful example for the Policies section, aligned with the To-Do App context.


Reacting to Events with Policies

In dc-ts, Policies are designed to automatically react to events. They take an event and a state, and they return either a command or a failure. Policies are useful for implementing cross-entity logic, such as automatically updating related entities when conditions are met.

Example Scenario: Completing an Epic

Imagine your To-Do App includes the concept of Epics, which are groups of related To-Dos. When a To-Do is marked as completed, we may want to check if all the To-Dos in the corresponding Epic are completed. If they are, the Policy should trigger a CompleteEpicCmd to mark the Epic itself as completed.


Defining the Types

Event: ToDoDoneEvt

This event signifies that a single To-Do has been marked as completed.

import { EVT } from 'dc-ts'

type ToDoDoneData = { toDoId: string; epicId: string }
export type ToDoDoneEvt = EVT<'to-do-done', ToDoDoneData>

Command: CompleteEpicCmd

This command will mark an Epic as completed.

import { CMD } from 'dc-ts'

type CompleteEpicData = { epicId: string }
export type CompleteEpicCmd = CMD<'complete-epic', CompleteEpicData>

State: EpicState

The state includes the list of To-Dos under the Epic and tracks their completion status.

export type EpicState = {
    epicId: string
    toDos: Array<{ id: string; status: 'completed' | 'in-progress' }>
}

Implementing the Policy

Step 1: Parsing the State

The Policy first needs to validate that the Epic state has the expected structure.

import { safeParseTBox } from 'dc-ts'
import { EpicState } from './schema'

const _parseState: EpicCompletePy['parse'] = safeParseTBox(EpicState)

Step 2: Invariants

Invariants ensure that the provided event corresponds to a To-Do that belongs to the Epic.

import { fail, succeed } from 'dc-ts'

const _invariants: EpicCompletePy['invariant'] = 
    (evt) => (state) => {
        const toDoInEpic = state.toDos.some(todo => todo.id === evt.data.toDoId)

        if (!toDoInEpic) {
            return fail('todo_not_in_epic')
        }
        return succeed(state)
    }

Step 3: Constraints

The constraint checks whether all To-Dos in the Epic are marked as completed.

const _allToDosCompleted: EpicCompletePy['constrain'] = 
    (evt) => (state) => {
        const allCompleted = state.toDos.every(todo => todo.status === 'completed')

        if (!allCompleted) {
            return fail('not_all_todos_completed')
        }
        return succeed(state)
    }

Step 4: Execution

If the conditions are satisfied, the Policy should generate the CompleteEpicCmd.

import { DomainTrace, dtFromMsg, newCmd } from 'dc-ts'

const _execute: EpicCompletePy['execute'] = 
    (evt) => (state) => {
        const dt: DomainTrace = dtFromMsg(evt)

        const cmdRes = newCmd<CompleteEpicCmd>()
            ('complete-epic')
            ({ epicId: state.epicId })
            (dt)

        return cmdRes
    }

Step 5: Composing the Policy

Now we combine all the building blocks using the composePy utility:

import { composePy } from 'dc-ts'

export const completeEpicPy: EpicCompletePy['fn'] = 
    composePy<EpicCompletePy>(_parseState)
        (_invariants)
        ([_allToDosCompleted])
        (_execute)

Conclusion

dc-ts provides a robust framework for building domain layers in TypeScript using functional programming principles. By leveraging its utilities and patterns, you can create maintainable, type-safe, and scalable domain logic. For more details, explore the documentation and examples.

0.0.3-unstable

4 months ago

0.0.7-unstable

4 months ago

0.0.6-unstable

4 months ago

0.0.4-unstable

4 months ago

0.0.1-unstable

4 months ago

0.0.9-unstable

4 months ago

0.0.2-unstable

4 months ago

0.0.8-unstable

4 months ago

0.0.5-unstable

4 months ago

0.0.8

5 years ago

0.0.7

5 years ago

0.0.6

5 years ago

0.0.5

5 years ago

0.0.4

5 years ago

0.0.3

5 years ago

0.0.2

5 years ago

0.0.1

5 years ago