BatuhanW/interactor

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# Interactor

![CI](https://github.com/BatuhanW/interactor/workflows/main/badge.svg)
[![Maintainability](https://api.codeclimate.com/v1/badges/4315aa36678fe4181b77/maintainability)](https://codeclimate.com/github/BatuhanW/interactor/maintainability)
[![Test Coverage](https://api.codeclimate.com/v1/badges/4315aa36678fe4181b77/test_coverage)](https://codeclimate.com/github/BatuhanW/interactor/test_coverage)

Typescript implementation of [collectiveidea/interactor](https://github.com/collectiveidea/interactor) Ruby gem.

## Installation

```bash
npm i @batuhanw/interactor
```

or

```bash
yarn add @batuhanw/interactor
```

## Getting Started

Interactors are simple, single-purpose objects used to encapsulate your application's business logic. Each interactor represents one thing your application does.

### Interactor

To define an interactor, simply create a class that extends from `Interactor` and add `call()` instance method.

```typescript
class CreateOrder extends Interactor {
  async call() {
    // Do something
  }
}
```

An interactor is used by invoking its `static call()` method.

```typescript
CreateOrder.call();
```

When an `Interactor`'s `static call()` method is invoked, it builds an instance of `Context` from given object.

```typescript
CreateOrder.call({ params: { sku: 'sku', userId: 1 } });
```

And `Context` is accessible within the interactor's `call()` instance method.

```typescript
class CreateOrder extends Interactor {
  async call() {
    const { params } = this.context;

    params.sku; // => 'sku'
    params.userId; // => 1
  }
}
```

An interactor can also mutate its `Context`.

```typescript
class CreateOrder extends Interactor {
  async call() {
    const { params } = this.context;

    this.context.order = await OrderService.create(params);

    this.context.order; // => Order { id: 1, sku: 'sku', userId: 1 }
  }
}
```

When completed, interactor return its `Context` under `result` key of the object.

```typescript
const { result } = await CreateOrder.call({ params: { sku: 'sku', userId: 1 } });

result.params; // { sku: 'sku', userId: 1 }
result.order; // Order { id: 1, sku: 'sku', userId: 1 }
```

If something goes wrong in your interactor, you can mark context as failed.

```typescript
class CreateOrder extends Interactor {
  async call() {
    const { params } = this.context;

    try {
      this.context.order = await OrderService.create(params);
    } catch (error) {
      this.context.fail();
    }
  }
}
```

If you pass an object to the `fail()` method, it also updates the context. The followings are equivalent:

```typescript
this.context.error = 'invalid SKU';
this.context.fail();
```

or

```typescript
this.context.fail({ error: 'invalid SKU' });
```

You can ask a context if it's a failure.

```typescript
const { result } = CreateOrder.call({ sku: 'sku', userId: 1 });

result.isFailure(); // => false
result.error; // => 'invalid SKU'
```

or if it's a success

```typescript
const { result } = CreateOrder.call({ sku: 'sku', userId: 1 });

result.isSuccess(); // => true
result.order; // => Order { id: 1, sku: 'sku', userId: 1 }
```

When `Context` is failed with `this.context.fail({ .. })` method, it throws `InteractorFailure` exception.

By default, `InteractorFailure` exception is swallowed by interactor.

It's possible to change this behaviour.

Calling the Interactor with `catchInteractorFailure: false` will throw `InteractorFailure` error.
This error has `context` field that gets populated with current context at the time of failure.

```typescript
try {
  const { result } = await CreateOrder.call(
    { params: { sku: 'sku', userId: 1 } },
    { catchInteractorFailure: false },
  );
} catch (e) {
  if (e instanceof InteractorFailure) {
    e.context; // Context { params: { sku: 1, userId: 1 }, error: 'invalid SKU' }
  }
}
```

### Organizer

Organizer is a variation of interactor. It can run multiple interactors in order.

```typescript
class PlaceOrder extends Organizer {
  Interactors = [CreateOrder, ReserveProduct];
}
```

And these interactors share the same context.

```typescript
class CreateOrder extends Interactor {
  async call() {
    this.context.order = await OrderService.create(this.context.params);
  }
}

class ReserveProduct extends Interactor {
  async call() {
    const { order } = this.context;

    this.context.reservation = await ReservationService.create({ order });
  }
}
```

If any of the interactors fails, `Organizer` calls `rollback()` instance method on successfully called interactors in reverse order.
Organizer won't call `rollback()` on the failed interactor itself.

```typescript
class PlaceOrder extends Organizer {
  Interactors = [CreateOrder, ReserveProduct, ChargeCustomer];
}

class CreateOrder extends Interactor {
  // Called 1st
  async call() {
    this.context.order = await OrderService.create(this.context.params);
  }

  // Called 5th
  async rollback() {
    const { order } = this.context;

    await OrderService.markAsFailed({ order });
  }
}

class ReserveProduct extends Interactor {
  // Called 2nd
  async call() {
    const { order } = this.context;

    this.context.reservation = await ReservationService.create({ order });
  }

  // Called 4th
  async rollback() {
    const { id } = this.context.reservation;

    await ReservationService.destroy(id);
  }
}

class ChargeCustomer extends Interactor {
  // # Called 3rd
  async call() {
    const { user, order } = this.context;

    const payment = await PaymentService.charge({ user, order });

    this.context.fail({ error: 'payment failed' });
  }
}
```

It's also possible to organize other organizers.

```typescript
class PlaceOrder extends Organizer {
  Interactors = [
    CreateOrder, // => CreateOrder interactor
    ReserveProduct, // ReserveProduct organizer => [ValidateStock, CreateReservation]
    SendNotifications, // SendNotifications organizer => [SendEmail, SendPush, SendSMS]
  ];
}
```

Check `./examples` directory for more.