# Add a consumer

**Goal:** your module reacts to an event another module publishes, such as counting items when `tasks.item.created.v1` arrives.

**Use it** for work that may happen after the producer committed: counters, read models, notifications, starting your own process.

**Do not use it** when the producer needs your answer before it commits (that is a [contract](<https://sandloom.dev/docs/modules/between/add-contract/index.md>)), or to keep a list page in sync from several channels with versions (that is a [projection](<https://sandloom.dev/docs/modules/between/add-projection/index.md>), which generates its consumers for you). Delivery is asynchronous and at-least-once; there is no ordering guarantee between events.

## Prerequisites

- Your module exists ([Add a module](<https://sandloom.dev/docs/modules/create/index.md>)).
- The producer's schema is known to the generator, in one of two places:
- the producer is a module in the same app (its `Event` class is found in the tree);
- or the producer's exported schema file is in your shared DTO package, `<app>/shared/events/<channel>.json` (written by `sandloom generate` in the producer's repository).

Otherwise you pass the fields yourself with `--field`, and the view is marked unverified. - If `consumers.py` already exists, it must parse and import `Event, on` from `sandloom.sdk` (and the types the fields need).

## Steps

### 1\. Run the generator in the consuming module

```
sandloom add consumer board notes.item.created.v1
```

When no producer schema is found, the generator refuses and asks for the fields:

```
sandloom add consumer board billing.invoice.paid.v1 \
    --field invoice_id:UUID --field tenant_id:UUID --field amount:int
```

The generator **appends** a view class and an `@on` handler to `<module>/consumers.py` (created when absent).

### 2\. Read what it wrote

Both runs above, in one file:

```
"""Consumers of the board module."""

from uuid import UUID

from sandloom.sdk import Event, on


class ItemCreated(Event, channel="notes.item.created.v1"):
    """A view: fields of the producer (notes/events.py)."""

    item_id: UUID
    tenant_id: UUID


@on(ItemCreated)
async def on_item_created(event: ItemCreated) -> None:
    """Runs once per event (durable), with the event's tenant bound."""


class InvoicePaid(Event, channel="billing.invoice.paid.v1"):  # unverified: no producer schema was found
    """A view: fields from --field."""

    invoice_id: UUID
    tenant_id: UUID
    amount: int


@on(InvoicePaid)
async def on_invoice_paid(event: InvoicePaid) -> None:
    """Runs once per event (durable), with the event's tenant bound."""
```

- **A view, not a producer.** The channel's first segment (`notes`) is another module, so the class is a *view* of that channel: it is not registered as a schema, `.publish()` on it is refused, and payload fields it does not declare are ignored. You may delete fields you do not need; never add one the producer does not send.
- **Verified or not.** A view built from the producer's schema is checked against it. A view built from `--field` carries the comment `# unverified: no producer schema was found` and is reported as unverified until the producer's schema file is available.
- **The handler.** `@on(E)` registers the function as a durable consumer named after the function. Each delivery runs in its own unit of work; `db()` is that unit's session. The envelope id is claimed in the same unit, so a redelivered event applies its effect once. The tenant is bound from the event's `tenant_id` field.

### 3\. Write the effect

Fill the handler with the effect, through your module's services. The tutorial app's `stats` module counts items:

```
class ItemCreated(Event, channel="tasks.item.created.v1"):
    """A view: fields of the producer (tasks/events.py)."""

    item_id: UUID
    tenant_id: UUID


@on(ItemCreated)
async def on_item_created(event: ItemCreated) -> None:
    """Runs once per event (durable), with the event's tenant bound."""
    await services.record_item()
```

```
async def _counter() -> StatsItemCount | None:
    # TenantScoped rows are filtered by the bound tenant: at most one row.
    result = await db().execute(select(StatsItemCount))
    return result.scalar_one_or_none()


async def record_item() -> None:
    counter = await _counter()
    if counter is None:
        db().add(StatsItemCount(items=1))
    else:
        counter.items += 1
    await db().flush()


async def item_count() -> int:
    counter = await _counter()
    return 0 if counter is None else counter.items
```

Keep the effect inside the handler's unit of work. An effect outside the database (an email, a call to a provider) is not covered by the claim: make it idempotent on the event's id or the business key.

To stop retrying an event that can never succeed, raise `PoisonEventError` (from `sandloom.sdk.events`): the event goes to the dead-letter queue at once. Any other exception rolls back the unit and the event is redelivered, then dead-lettered after the delivery budget.

### 4\. Options you may need

`@on` takes the options of the underlying consumer registration. The defaults are right for most handlers:

| Option | Default | When to change it |
| --- | --- | --- |
| `name` | the function name | two handlers of one channel in one module need distinct names |
| `durable` | `True` | `False` only for an effect that is harmless when repeated |
| `required` | `True` | `False` if the app may start without this consumer running |
| `dead_letter` | `True` | `False` to retry forever with capped backoff (a `PoisonEventError` still dead-letters) |
| `start_from` | `"earliest"` | `"latest"` or a datetime for a new consumer that must skip history |
| `group` | `None` (`<module>:<name>`) | pin an explicit group id, for example to drain a group created under another name |
| `database` | `"default"` | the module's tables live in another database |
| `tenant` | `"derive"` | `"<field>"` for another UUID field, `None` to bind no tenant |

## Verify

Deliver the event in a test with `deliver`. With `times=2` the same envelope id arrives twice, which proves the effect is applied once:

```
async def test_a_redelivered_event_is_counted_once(client: Any, db: Any) -> None:
    tenant = uuid4()
    _, headers = as_user(tenant_id=tenant)
    event = ItemCreated(item_id=uuid4(), tenant_id=tenant)
    await deliver(event, times=2, session=db)  # the same envelope id twice
    response = await client.get("/api/v1/stats/count", headers=headers)
    assert response.status_code == 200, response.text
    assert response.json() == {"items": 1}


async def test_counts_are_per_tenant(client: Any, db: Any) -> None:
    await deliver(ItemCreated(item_id=uuid4(), tenant_id=uuid4()), session=db)
    _, headers = as_user(tenant_id=uuid4())  # another tenant
    response = await client.get("/api/v1/stats/count", headers=headers)
    assert response.json() == {"items": 0}
```

```
sandloom test stats
```

## Errors you may hit

- [SANDLOOM-B016](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b016>): the handler's parameter is not the event class, a consumer name is used twice, or the tenant field does not exist on the event.
- [SANDLOOM-B015](<https://sandloom.dev/docs/reference/error-codes/#sandloom-b015>): the view's channel is not versioned, or a field is not a wire type.
- [SANDLOOM-C025](<https://sandloom.dev/docs/reference/error-codes/#sandloom-c025>): the view does not match the producer's schema, is unverified, or calls `.publish()`.
- [SANDLOOM-R001](<https://sandloom.dev/docs/reference/error-codes/#sandloom-r001>): the handler touched a tenant table with no tenant bound (the event has no `tenant_id`, or `tenant=None`).

## Related

- [Events and consumers](<https://sandloom.dev/docs/modules/between/events/index.md>): delivery, the dead-letter queue, consumer groups.
- [Add an event](<https://sandloom.dev/docs/modules/between/add-event/index.md>): the producer side.
- [Add a projection](<https://sandloom.dev/docs/modules/between/add-projection/index.md>): a read model fed by events.
- [Testing modules](<https://sandloom.dev/docs/toolchain/testing/index.md>).
