Arch

Installation#

npm i @archimedes/arch -SE

Use cases#

In archimedes we differentiate between queries and commands.

In order to have a query or command you must implement the internalExecute method and extend from the base class Query or Command. This method always returns a promise.

The responsibility of use cases is to orchestrate and call other use cases and business logic. Every action a user can perform should be represented with a use case.

important

To execute a use case you must call the execute method instead of the internalExecute.

Queries#

A query is a use case that gets information from the system (immutable). Queries can have parameters and return something.

import { Query } from '@archimedes/arch'
export class FooQry extends Query<number> {
async internalExecute(): Promise<number> {
return 42
}
}
const fooQry = new FooQry()
const result = await fooQry.execute()
result // 42

You can specify what parameters you can pass as follows:

import { Query } from '@archimedes/arch'
export class BarQry extends Query<number, string> {
async internalExecute(value: string): Promise<number> {
return value === '' ? 0 : 42
}
}
const barQry = new BarQry()
const result = await barQry.execute('')
result // 0

In case you want to pass more than one parameter you can pass it as an object:

import { Query } from '@archimedes/arch'
export class BarQry extends Query<number, { a: number; b: number }> {
async internalExecute(value: { a: number; b: number }): Promise<number> {
return value === '' ? value.a : value.b
}
}
const barQry = new BarQry()
const result = await barQry.execute({ a: 1, b: 2 })
result // 0

Commands#

A command is a use case that performs an action or a side effect in the system (mutable). Commands can have parameters and should not return anything

import { Command } from '@archimedes/arch'
export class BazCmd extends Command {
async internalExecute() {
console.log('Hello world!')
}
}
const bazQry = new BazCmd()
await bazQry.execute() // 'Hello world!

Subscribe#

Sometimes you might want to execute some logic whenever a Query or a Command has executed:

import { Query } from '@archimedes/arch'
export class FooQry extends Query<number> {
async internalExecute(): Promise<number> {
return 42
}
}
let called = false
const fooQry = new FooQry()
fooQry.subscribe(() => (called = true))
await fooQry.execute()
called // true

In the subscribe callback you can receive additional information like the param used to execute the use case, the result and the executionOptions:

import { Query } from '@archimedes/arch'
export class FooQry extends Query<number> {
async internalExecute(): Promise<number> {
return 42
}
}
let called = false
const fooQry = new FooQry()
fooQry.subscribe(({ param, result, executionOptions }) => {
console.log(param, result, executionOptions)
called = true
})
await fooQry.execute()
called // true

When you subscribe a unique id is returned. This id is used to unsubscribe.

let calls = 0
const fooQry = new FooQry()
const id = fooQry.subscribe(() => calls++)
await fooQry.execute()
fooQry.unsubscribe(id)
await fooQry.execute()
calls // 1
important

Remember to unsubscribe when finished with a use case, if not you might have memory leaks!

Execution Options#

When you execute a use case you can pass options as the second parameter:

const quxQry = new QuxCmd()
await quxQry.execute(1, {
inlineError: true
})

If query or command does not have any parameters you have to pass undefined:

const quxQry = new QuxCmd()
await quxQry.execute(undefined, {
inlineError: true
})

You can define your own execution options typings by creating a archimedes.d.ts in the root of your project with the following contents:

import '@archimedes/arch'
declare module '@archimedes/arch' {
interface ExecutionOptions {
extraOption: string
inlineError: boolean
invalidateCache: boolean
}
}

If you do so, you can have a type safe way to pass custom options:

const quxQry = new QuxCmd()
await quxQry.execute(undefined, {
extraOption: 'foo'
})

Chain of responsibility#

Archimedes configures a chain of responsibility which allows a use case to be processed through links.

This means that for each action the user performs in your app you can execute arbitrary code, like for example: logging the request, caching data, handling errors and much more in a flexible yet robust way.

In order to create the chain you have to do this as soon as possible in your app:

Archimedes.createChain([new ExecutorLink(), new LoggerLink(console)])

You can configure the chain however you want, even dynamically:

if (isProduction) {
Archimedes.createChain([new ExecutorLink()])
} else {
Archimedes.createChain([new ExecutorLink(), new LoggerLink(console)])
}

A complete chain looks like this:

Archimedes.createChain([
new CacheLink(new CacheManager()),
new ExecutorLink(),
new LoggerLink(console),
new NotificationLink(new NotificationCenter())
])

Now, when you create a use case like follows:

import { Query } from '@archimedes/arch'
export class FooQry extends Query<number> {
async internalExecute(): Promise<number> {
return 42
}
}

And execute it:

const fooQry = new FooQry()
await fooQry.execute()

The use case will be cached, executed, a log will be shown with the result, the date and if an error happens, the notification center will be notified. And there is even more! You can create custom links.

Links#

ExecutorLink#

This link merely executes the use case. It is always needed if you want to execute the use cases.

LoggerLink#

This link logs information about the execution of the use case, like parameters, result and name of the use case:

BazQry
Parameters: -
Result:
42

It receives an object that conforms the Logger interface:

export interface Logger {
log<T>(message: T): void
}

For example, the window.console conforms that interface, so you can either use that:

// Local logger
new LoggerLink(console)

or any other implementation, like a custom remote logger:

class RemoteLogger implements Logger {
log<T>(message: T): void {
fetch('remote.logger.com', { body: message })
}
}
new LoggerLink(new RemoteLogger())

NotificationLink#

This link is meant to capture errors occurred when executing a use case. This link should be placed after ExecutorLink. When an error occurs it will notify the NotificationCenter. From the UI you can subscribe to the NotificationCenter in order to show the user an error message:

import { NotificationCenter } from './notification-center'
import { Observer } from './observer'
class ErrorAlerter implements Observer {
update(subject: NotificationCenter) {
subject.notifications.forEach(x => {
alert(x.message)
})
}
}
new NotificationCenter().register(new ErrorAlerter())

If you want a use case not to show an error when it fails you can execute the use case with the inlineError defined in the execution options

CacheLink#

This link caches the results for all queries with all parameters combination. To learn more about cache move to cache section.

important

The ExecutorLink is always required, even if you use the CacheLink.

important

If you want to use CacheLink and your framework mangles the name of the classes (like Angular does) you should either disable that option or use the @UseCaseKey decorator in all your use cases (queries and commands).

For example, to disable name mangling in Angular when you build the application set NG_BUILD_MANGLE=false in the package.json's script.

"scripts": {
"build": "NG_BUILD_MANGLE=false ng build",
},

To learn more about cache move to cache section.

NullLink#

This link throws an error when is called.

EmptyLink#

This link does nothing when called.

Custom links#

You can create your own custom links by extending the BaseLink class. For instance imagine if we wanted to create a link that starts a spinner when executing an use case:

import { BaseLink } from './base-link'
import { Context } from './context'
import { Loading } from '../loading/loading'
export class LoadingLink extends BaseLink {
constructor(private readonly loading: Loading) {
super()
}
async next(context: Context): Promise<void> {
this.loading.start()
context.result = context.result?.finally(() => {
this.loading.end()
})
this.nextLink.next(context)
}
}

And then register it in the chain of responsibility:

Archimedes.createChain([new LoadingLink(new Loading()), new ExecutorLink()])

Cache#

important

It's important to activate emitDecoratorMetadata to true in the tsconfig.json compiler's options. See the examples directory for more information.

UseCaseKey#

To manage properly the cache using CacheLink it is necessary to set a key that identifies each use case. To do this we must use the UseCaseKey decorator passing as parameter the name we want to give to our use case (we recommend using the same name of the class to simplify).

@UseCaseKey('GetTodosQry')
export class GetTodosQry extends Query<Todo[]> {}

Invalidate cache decorator#

You can automatically invalidate the cache of dependant use cases using the InvalidateCache decorator in conjunction with the CacheLink link. If use case a depends on use case b, and use case b depends on use case c (abc️) if we invalidate the cache of use case a we should invalidate the cache of use case b and c too. We should add this decorator to all use cases we want this handled.

@InvalidateCache
@UseCaseKey('AQry')
export class AQry extends Query {
constructor(private readonly bQry: BQry) {
super()
}
async internalExecute() {
return this.bQry.execute()
}
}

Cache invalidations#

If you want to set that certain commands or queries invalidate the cache of other commands and queries you can set the cache invalidations with CacheInvalidations.set method. You should indicate the key of the use case that will invalidate the cache and the invalidations that this use case will perform when executed.

CacheInvalidations.set('CreateTodoCmd', ['GetTodosQry'])

These invalidations can be a list of use case keys to invalidate when the first one is executed, for example:

CacheInvalidations.set('CreateTodoCmd', ['GetTodosQry', 'GetTodoByIdQry'])

Or you can also use some one of these invalidation policies:

  • ALL: The use case will invalidate all the cache of all the use cases
  • NO_CACHE: The use case will never be cached
CacheInvalidations.set(FooCmd.name, [InvalidationPolicy.ALL])

Invalidate cache in runtime#

Sometimes you want to execute a Query invalidating the cache for this specific execution, for example, refresh an email list. To do that, you can use invalidateCache: true in the execution options object.

await this.getTodosQry.execute(undefined, { invalidateCache: true })