Skip to content
Back to skills

Split Aggregate

ASecurity

Split an oversized aggregate into two along its consistency boundary. Detects an aggregate that has grown two concerns and too many entities, extracts the second concern into its own aggregate, links the two by identity, and carries the cross-aggregate step with a domain event. Use when the user says "split this aggregate", "my aggregate is too big", "extract an aggregate", "aggregate has too many entities", "AGGREGATE_TOO_LARGE", "CROSS_AGGREGATE_REFERENCE", "one aggregate two concerns", "br...

  • 46 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 24, 2026
developmentpythongoreactrefactoring

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned September 24, 2026

npx -y skills add proteanhq/protean --skill split-aggregate --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Split Aggregate?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Split Aggregate
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/proteanhq-split-aggregate/badge)](https://www.skillsdirectory.com/skills/proteanhq-split-aggregate)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: split-aggregate
description: Split an oversized aggregate into two along its consistency boundary. Detects an aggregate that has grown two concerns and too many entities, extracts the second concern into its own aggregate, links the two by identity, and carries the cross-aggregate step with a domain event. Use when the user says "split this aggregate", "my aggregate is too big", "extract an aggregate", "aggregate has too many entities", "AGGREGATE_TOO_LARGE", "CROSS_AGGREGATE_REFERENCE", "one aggregate two concerns", "break up a large aggregate", or when an audit reports an aggregate over the size limit or a reference that crosses an aggregate boundary.
license: Apache-2.0
compatibility: "Requires Python 3.11+, protean framework"
metadata:
  author: proteanhq
  version: "0.1"
  category: workflow
  composes: [aggregate, refactor-move-logic-to-aggregate, event, domain-service]
  diagnostic_codes: [AGGREGATE_TOO_LARGE, CROSS_AGGREGATE_REFERENCE]
---

# Split Aggregate

> **Illustrative, guided refactoring.** This is a before→after walkthrough.
> Recognize the smell, pick the seam yourself, and apply the change by hand; the
> framework does not transform the code for you. The `split_order_before.py`
> asset is the oversized starting point; `split_order_after.py` is the target.

An aggregate should hold one consistency boundary. When it grows a second
concern, with its own entities and its own lifecycle, the cluster gets large and
`check` reports `AGGREGATE_TOO_LARGE`: it counts more child entities in one
cluster than `[lint] aggregate_size_limit` allows (default five). That count is
the signal to split.

## Before: one aggregate, two concerns

[split_order_before.py](assets/split_order_before.py) is an `Order` that carries
both the order concern (items, discounts, gift wraps) and the fulfilment concern
(packages, tracking events, delivery attempts). Six child entities in one
cluster, over the default limit of five, so `check` reports it:

```
AGGREGATE_TOO_LARGE  Aggregate `Order` has 6 entities (limit: 5)
```

## Pick the seam

The seam is the invariant boundary. Read the entities and group the ones that
must stay consistent together in one transaction. In the Order example, the
order concern is one such group and the fulfilment concern is another. A change
to a tracking event can commit on its own without a discount changing at the
same time, so they are two boundaries and belong in two aggregates.

When the boundary is unclear, use lifecycle as the tiebreak: state that is
created, changed, and retired on a different schedule is a second aggregate. The
[finding-the-seam](references/finding-the-seam.md) reference covers both rules in
full.

## After: two aggregates, linked by identity and an event

[split_order_after.py](assets/split_order_after.py) extracts the fulfilment
concern into a `Shipment` aggregate. Each cluster now holds three entities, under
the limit, so `AGGREGATE_TOO_LARGE` is gone.

### 1. Move the second concern into its own aggregate

Create the new aggregate and move the extracted entities under it with
`part_of`. The entity classes are unchanged; only their parent changes.

```python
@domain.aggregate
class Shipment:
    order_id: Identifier(identifier=True)   # identity link back to Order
    status: String(default="pending")

    packages = HasMany("Package")
    tracking_events = HasMany("TrackingEvent")
    delivery_attempts = HasMany("DeliveryAttempt")
```

### 2. Link by identity

`Shipment` names the order it fulfils by holding the order's id in a plain
`Identifier` field. Holding a `Reference` to the `Order` root would reach across
the boundary, which is what `check` reports as `CROSS_AGGREGATE_REFERENCE`:

```python
# Wrong: a Reference across an aggregate boundary re-couples the two clusters.
order = Reference("Order")
```

The identity field carries the link and keeps the two aggregates as separate
loading and locking units. Load the `Order` through its own repository when a
`Shipment` needs it.

In this example an order has one shipment, so `order_id` is also `Shipment`'s
own identity (`identifier=True`). Pick that deliberately: it is what makes the
step below safe to repeat. Where an order can ship in several parcels, give
`Shipment` its own id and keep `order_id` a plain `required=True` field. The
guard below has to change with it: `order_id` no longer names one shipment, so
it cannot be the retry key either, and refusing every repeated `order_id` would
refuse the second parcel too. Carry a deterministic shipment id on the command
and look that up instead.

### 3. Carry the cross-aggregate step with a domain event

The two aggregates commit in separate transactions, so the step that spans them
happens as two writes joined by an event. `Order` raises `OrderPlaced` when it is
placed. An event handler in `Order`'s own cluster reacts and issues a
`StartShipment` command, and `Shipment`'s command handler starts the shipment.

```python
@domain.event_handler(part_of=Order)
class ShipmentInitiation:
    @handle(OrderPlaced)
    def on_order_placed(self, event: OrderPlaced) -> None:
        current_domain.process(StartShipment(order_id=event.order_id))
```

Events are delivered at least once, so write the receiving end to survive a
repeat. `OrderPlaced` can arrive twice, which reissues `StartShipment`, so
`Shipment`'s command handler returns without doing anything when the shipment is
already open:

```python
@handle(StartShipment)
def start_shipment(self, command: StartShipment) -> None:
    repository = current_domain.repository_for(Shipment)
    try:
        repository.get(command.order_id)
    except ObjectNotFoundError:
        repository.add(Shipment.start(command.order_id))
    else:
        return   # already open; starting over would discard its progress
```

Without both the deterministic id and this guard, a second delivery inserts a
second shipment for the same order. See
[Idempotent Event Handlers](https://docs.proteanhq.com/patterns/idempotent-event-handlers/)
for the full set of strategies.

The handler stays in `Order`'s cluster because it reacts to `Order`'s own event.
A handler that reacts to another cluster's event trips
`EVENT_HANDLER_FOREIGN_EVENT`; for a flow with several causally dependent steps,
reach for a process manager. See [event](../event/SKILL.md) for raising the event
and [domain-service](../domain-service/SKILL.md) for a step whose logic spans
both aggregates.

## Common mistakes

1. **Cut on the consistency boundary, wherever it falls.** Two entities that must
   stay correct together in one transaction stay in one aggregate, however large
   the cluster grows.

2. **Do not link the new aggregate back with a `Reference`.** A `Reference` to
   the other root re-couples the clusters and brings back
   `CROSS_AGGREGATE_REFERENCE`. Hold the other aggregate's id by value.

3. **Keep the event handler in the cluster that owns the event.** A handler that
   reacts to another cluster's event trips `EVENT_HANDLER_FOREIGN_EVENT`. Hand
   off to the other aggregate with a command.

4. **Make the cross-aggregate step safe to repeat.** Events are delivered at
   least once. A step that creates the other aggregate will create it twice
   unless the new aggregate has a deterministic id and the handler no-ops when
   it already exists.

## Related skills

- [aggregate](../aggregate/SKILL.md): the aggregate and its consistency boundary.
- [refactor-move-logic-to-aggregate](../refactor-move-logic-to-aggregate/SKILL.md):
  the companion refactor for logic that has leaked out of an aggregate.
- [event](../event/SKILL.md): the domain event that links the two aggregates.
- [domain-service](../domain-service/SKILL.md): for business logic that spans two
  aggregates once they are split.

## Verify your work

- [Verify with check](../../references/verify-with-check.md): run `check` and
  resolve what it reports, including any `AGGREGATE_TOO_LARGE` or
  `CROSS_AGGREGATE_REFERENCE`.

Files in this skill

  • SKILL.md7.8 KB
  • assets/split_order_after.py8.3 KB
  • assets/split_order_before.py3.7 KB
  • references/finding-the-seam.md3.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.