Skip to content

Capabilities ​

PureLogic provides 4 basic capabilities that can be used to compose your pure domain logic:

Each capability is described in detail on its own page. This page covers how to use and combine them.

PureLogic also provides EventSourcing, a higher-level pattern built on top of State and Writer that ensures state can only change through recorded events.

Calling capability functions ​

Every capability exposes its functions in 3 different ways. Let's use State as an example:

1. Root-level functions ​

The simplest way. Functions like get, set, read, write, fail are available directly after import purelogic.*:

scala
import purelogic.*

def program(using State[Account]): Unit = {
  val balance = get(_.balance)
  set(Account(balance + 100))
}

This is the recommended way for most code. It is concise and reads naturally.

2. Companion object functions ​

You can also call functions on the capability's companion object:

scala
def program(using State[Account]): Unit = {
  val balance = State.get(_.balance)
  State.set(Account(balance + 100))
}

This is equivalent to the root-level version and can be useful for clarity when multiple capabilities are in scope.

3. Named capability instances ​

You can name the capability instance in the using clause and call methods on it directly:

scala
def program(using account: State[Account], cart: State[Cart]): Unit = {
  val balance = account.get(_.balance)
  val items   = cart.get(_.items)
  account.set(Account(balance - 100))
  cart.set(Cart(items :+ newItem))
}

This is essential when you have two capabilities with the same type (e.g. two State[Int]), where the compiler can't disambiguate on its own. It's also useful for readability when you have several capabilities with different type parameters.

The Logic type alias ​

When your function uses all 4 capabilities, the signature can get verbose:

scala
def process(order: Order)(using Reader[Config], Writer[Event], State[Account], Abort[AppError]): Result = ???

PureLogic provides a type alias to simplify this:

scala
type Logic[R, W, S, E, A] = (Reader[R], Writer[W], State[S], Abort[E]) ?=> A

So you can write:

scala
def process(order: Order): Logic[Config, Event, Account, AppError, Result] = ???

Of course, you can also define your own type aliases tailored to your application:

scala
type MyProgram[A] = (Reader[Config], State[Account], Abort[AppError]) ?=> A

def process(order: Order): MyProgram[Result] = ???

Running a program ​

Each capability has an apply method that provides the capability and returns the result:

  • Reader(value)(body) returns the result A
  • Writer(body) returns (Vector[W], A)
  • State(initial)(body) returns (S, A)
  • Abort(body) returns Either[E, A]

These can be nested in any order:

scala
val (logs, result) =
  Reader(config) {
    Writer {
      State(initialAccount) {
        Abort {
          myProgram
        }
      }
    }
  }
// result: Either[AppError, (Account, Unit)]
// logs: Vector[Event]

Logic.run ​

For the common case of using all 4 capabilities together, Logic.run is a convenience function:

scala
val (logs, result) = Logic.run(state = initialAccount, reader = config) {
  myProgram
}
// result: Either[AppError, (Account, Unit)]
// logs: Vector[Event]

This is equivalent to Reader(reader)(Writer(Abort(State(initial)(body)))).

Logic.runInfallible ​

If your program does not use Abort (i.e. it cannot fail), you can use Logic.runInfallible to avoid the Either wrapper:

scala
val (logs, finalState, result) = Logic.runInfallible(state = initialAccount, reader = config) {
  myInfallibleProgram
}

Logic.simulate and Logic.simulateWith ​

These let you run a sub-program in isolation without impacting the current state or accumulated writes. This is useful when your logic needs to see what the result of a program would be without committing its side effects. Errors are still propagated to the outer program via Abort.

scala
// Run with custom state and reader, without affecting the outer state or writes
val result = Logic.simulateWith(mockState = Account(100), mockEnv = Config(10)) {
  myProgram
}

Logic.simulate does the same but reuses the Reader and State from the outer scope instead of providing new values.