# Data-Access Architecture
A system for organising database integration code built on Hasql.
This is a normative reference. It specifies **boundaries and dependency direction** — architecture in the strict sense — and deliberately says nothing about what goes inside a module beyond what the boundaries require. Rules are numbered so they can be cited. Each carries its rationale, because a rule you cannot re-derive is a rule you will misapply.
Almost every rule below follows from §2. A reader who internalises that table can reconstruct the rest instead of memorising it.
## Contents
1. [Applicability](#1-applicability)
2. [The capability ladder](#2-the-capability-ladder)
3. [The layer criterion](#3-the-layer-criterion)
4. [Why two layers](#4-why-two-layers)
5. [Namespaces and naming](#5-namespaces-and-naming)
6. [The class family](#6-the-class-family)
7. [Errors](#7-errors)
8. [Transactions in practice](#8-transactions-in-practice)
9. [Testing](#9-testing)
10. [Packaging](#10-packaging)
11. [Appendix: worked example](#11-appendix-worked-example)
---
## 1. Applicability
This system pays for itself when your application's types differ in shape from your schema's types, which is almost always the case in PostgreSQL — composite types and outer joins make nearly everything nullable, so a faithful mapping of a seven-column table can arrive as seven `Maybe`s even where the columns are `NOT NULL`.
**It does not apply when the two shapes are identical.** If every conversion in §5 would be `coerce`, you have two names for one thing and the second layer is ceremony. Use one layer.
**1.1.** Preserve the Mapping layer's *shape* even when you collapse the layers — statements in their own modules, parameter records, no domain conversion inside codecs. Collapsing is cheap to undo. Corrupting the Mapping layer's shape is not, because splitting later then means rewriting every codec rather than moving modules.
---
## 2. The capability ladder
Hasql offers four constructs. Each adds exactly one capability, and every capability below is a fact about its instance list rather than a matter of style.
| Construct | Composition | Atomicity | Error channel | IO |
| --- | --- | --- | --- | --- |
| `Statement` | none | — | — | — |
| `Pipeline` | `Applicative` | no | no | no |
| `Transaction` | `Monad` | yes, with retry | **no** | no |
| `Session` | `Monad` | no | **yes** (`MonadError SessionError`) | **yes** (`MonadIO`) |
**2.1. The ladder is not a total order.** `Pipeline` and `Transaction` are siblings, not degrees of the same thing. `Pipeline` is not weaker atomicity — it offers none at all, on a different axis entirely, which is why `hasql-transaction` has no pipeline support and a `Pipeline` cannot appear inside a `Transaction`.
**2.2. Choose the construct whose capability you need.**
- One statement → `Statement`.
- Several independent statements, fewer round trips → `Pipeline`.
- Several statements that must be atomic → `Transaction`.
- Typed errors derived from SQLSTATE, or raw libpq work such as `COPY` and `LISTEN`/`NOTIFY` → `Session`.
*Rationale.* A `Transaction` in this system means "these statements must be atomic". Using one where atomicity is not required destroys that signal, and a reader can no longer tell which transactions are load-bearing.
**2.3. `Transaction` bodies are idempotent by construction.** A transaction may run many times, because conflicts are retried (§8). It has no `MonadIO`, so the type prevents you from making a retry observable. Treat this as a designed property, not an accident.
**2.4. Typed domain errors cannot be produced inside a `Transaction`.** `catchError` is unavailable there. A SQLSTATE-derived error can only be produced one construct up, at `Session`. See §7.
**2.5. `Pipeline` is not a layer.** It offers neither dependent composition (no `Monad`, so no statement in a batch can branch on an earlier one's result) nor any guarantee, and it cannot nest inside the construct that has both. What it offers is fewer round trips — a function of network latency, which is a property of your deployment rather than of the operation's meaning. It is therefore an execution technique used inside a session, not a unit the architecture is organised around.
**2.6. There is no `Pipelines` namespace and no `IsPipeline` class.** A pipeline worth reusing is a plain function returning `Pipeline a`, which composes freely via `Applicative`. Such functions live beside the session that uses them.
*Rationale.* Every namespace in this system has a membership rule you can apply without judgement (§5.1). `Pipelines` would mean "batches someone thought worth naming", which predicts nothing. A namespace that sits beside `Statements` and `Transactions` also signals "a kind of operation", and a pipeline is an optimisation applied to an operation rather than a kind of one.
---
## 3. The layer criterion
There are two layers, and **one criterion decides everything, with no exceptions**:
> **Does it mention the domain?**
>
> No → **Mapping**. Yes → **Access**.
```mermaid
graph BT
subgraph Access
AT["Types
(conversions)"]
AS["Statements
(adapted)"]
AX["Transactions"]
ASE["Sessions ← public"]
end
subgraph Mapping
MT["Types"]
MS["Statements"]
MX["Transactions"]
end
D["Domain
(no hasql dependency)"]
MS --> MT
MX --> MS
AT --> MT
AT --> D
AS --> MS
AS --> AT
AX --> MX
AX --> AS
ASE --> AX
ASE --> AS
```
**3.1. Dependencies point one way only.** Mapping never imports Access, and neither layer is imported by the domain. This is checkable mechanically — grep for `import` of an `Access` module from inside `Mapping`.
**3.2. Callers may enter at whichever construct their operation needs.** There is no rule that everything must go through a transaction. Wrapping a single `select … where id = $1` in a `Transaction` is ceremony. The one-way rule constrains *dependencies between layers*, not which construct a caller reaches for.
**3.3. Statements are domain-free by construction, not by convention.** A `Statement`'s parameter and result types are determined by its SQL, and SQL is written against the schema, so a statement cannot mention a domain type unless a conversion is smuggled into its codec. That is the anti-pattern in §7.5.
**3.4. The Access layer never authors SQL.** Every Access statement is an adaptation of a Mapping statement via `dimap` and `refineResult` (§5.7). Each query's text therefore lives in exactly one place.
**3.5. Push predicates into SQL to keep transactions domain-free.** A domain rule expressible as a predicate over stored data belongs in the query, not in Haskell between two statements:
```sql
insert into album (artist_id, name)
select $1, $2
where (select count(*) from album where artist_id = $1) < $3
returning id
```
The transaction stays domain-free with an `Int64` limit parameter, and the *session* maps "no rows returned" to `QuotaExceeded`. This generalises to most rules of the form "only if the stored data satisfies P".
**3.6. What genuinely requires the domain inside a transaction is read-modify-write with Haskell-side computation.** Read a balance, apply a pricing function not expressible in SQL, write the result — atomically, so it cannot be split into two sessions. Or read a workflow state, check the transition against a domain-defined legality table, write the new state. In both, a domain function runs *between* two statements inside one transaction. Those transactions live in Access. Everything else tends to come out domain-free and belongs in Mapping.
---
## 4. Why two layers
The split's cost is a conversion function per type. Its benefits, stated as what actually breaks without it:
**4.1. The domain would depend on hasql.** For a codec to produce a domain type, that type needs an `IsScalar` instance, so either your domain package takes a dependency on the database driver or you carry orphan instances. This is worse than it sounds for PostgreSQL-specific types — a domain type refined from `PostgresqlTypes.Date` drags `postgresql-types` into code that has no business knowing what a database is.
**4.2. A refinement failure aborts the entire result set.** One invalid row in ten thousand and you get nothing — no skipping, no logging, no partial results. With conversion outside the codec you hold a `Vector` of rows and can `traverse` or partition as the operation requires.
**4.3. Typed errors are erased to `Text`.** Hasql does distinguish refinement failures from decode failures — `RowError` has a dedicated `RefinementRowError` constructor — but its payload is `Text`, so a typed domain error reconstructed from it must be parsed out of a string.
**4.4. Encode-side coverage narrows.** The Mapping layer's statement tests generate arbitrary parameters (§9.2). Refined parameter types generate only *valid* values, so the encoder is exercised against strictly less than the database can be sent.
**4.5. The Mapping layer stops being independently reusable.** A second consumer — an admin CLI, a backfill job, a reporting tool — cannot use a layer that has your application's domain baked into it.
Note what is *not* an argument here. There is no dependency cycle in the merged arrangement, since domain types can sit below both layers and compile fine. And per-column refinement is attached to a type rather than to a query, so it does not duplicate across statements. Both are things this document previously claimed and they do not hold.
---
## 5. Namespaces and naming
Both layers use the [aggregator namespace pattern](https://github.com/nikita-volkov/haskell-coding-standards/blob/master/patterns/aggregator-namespace.md).
```
MySpace.MusicCatalogue.Mapping.Types -- aggregator, Variant 2
MySpace.MusicCatalogue.Mapping.Types.Album
MySpace.MusicCatalogue.Mapping.Statements -- aggregator, Variant 2
MySpace.MusicCatalogue.Mapping.Statements.InsertAlbum
MySpace.MusicCatalogue.Mapping.Transactions -- aggregator, Variant 2
MySpace.MusicCatalogue.Access.Types.Album -- Variant 1 naming, no aggregator
MySpace.MusicCatalogue.Access.Statements.SelectAlbumById
MySpace.MusicCatalogue.Access.Transactions.RegisterAlbum
MySpace.MusicCatalogue.Access.Sessions -- aggregator, Variant 2, the public surface
```
**5.1. Every namespace has a membership rule applicable without judgement.**
| Namespace | Membership |
| --- | --- |
| `Mapping.Types` | every user-declared PostgreSQL type |
| `Mapping.Statements` | every query |
| `Mapping.Transactions` | every domain-free atomic multi-statement operation |
| `Access.Types` | every PG-specific type with a domain counterpart |
| `Access.Statements` | every Mapping statement needed in domain terms |
| `Access.Transactions` | every atomic operation requiring the domain |
| `Access.Sessions` | every operation the application exposes |
**5.2. The layer names appear as module path segments** in both packaging arrangements. This makes every import self-documenting about which side of the boundary it reaches, and it makes §3.1 greppable — which is the only mechanical enforcement available when both layers share a package.
**5.3. Mapping namespaces use aggregator Variant 2.** Sub-modules are `other-modules`, re-exported through the aggregator, and each module's primary type is named after the module. Exports are bounded by the class contract, so nothing collides.
**5.4. `Access.Sessions` is the only public namespace.** Everything else in Access is internal. Consumers want end-operations, and which construct a given operation happens to use is an implementation detail — hiding it lets you turn a transaction into a pipeline without breaking anyone.
*Cost, stated plainly.* A consumer can no longer compose two operations atomically, because sessions do not compose transactionally. Such a combination is itself an operation and belongs in `Access.Sessions`, which means new combinations require editing the layer rather than the call site.
**5.5. `Access.Types` uses Variant 1 naming without an aggregator**, keyed by **domain type**, with functions named for the PostgreSQL type:
```haskell
module MySpace.MusicCatalogue.Access.Types.Format where -- imported qualified as Format
toAlbumFormat :: Format -> Mapping.Types.AlbumFormat
fromAlbumFormat :: Mapping.Types.AlbumFormat -> Format
```
Reading `Format.fromAlbumFormat` at a call site names both sides. There is no root module, because Variant 1's root exists so callers can refer to the collection as a whole and no caller here ever does.
*Rationale for keying by domain type.* The Access layer speaks domain vocabulary throughout, so keying one of its namespaces by PostgreSQL type would make that namespace speak the wrong language. The one deliberate exception is `Access.Statements`, whose modules carry Mapping's names because they mirror Mapping statements one-for-one.
**5.6. `Access.Types` covers PG-specific sources only** — user-declared composites and enums, and `postgresql-types` scalars such as `Date` and `Numeric`. Refinements of plain `base` or `text` types are ordinary domain smart constructors and live in the domain, which can depend on `base` without consequence.
*Rationale.* The criterion is "may anything below Access depend on this source type?" It may not for PG-specific types, which forces the conversion up here. It may for `Int64`, so `Age.fromInt64` belongs with `Age`. This also excludes the junk-drawer case — an `Access.Types.Int8` module would collect every refinement in the application, grouping unrelated concepts by wire representation.
**5.7. Access statements are adaptations.** `Statement` has `Profunctor` and hasql exposes `refineResult`, so both sides adapt post hoc without touching Mapping:
```haskell
module MySpace.MusicCatalogue.Access.Statements.SelectAlbumById where
data SelectAlbumById = SelectAlbumById {id :: Domain.AlbumId}
instance IsStatement SelectAlbumById where
type Result SelectAlbumById = Domain.Album
statement =
refineResult
Album.fromAlbumRow
(lmap ((.id) >>> AlbumId.toInt8) Mapping.Statements.statement)
```
The parameter type is Access's own, so there is no instance collision with Mapping's.
**5.8. Naming follows the layer.** Mapping units are named for what the SQL does (`InsertAlbum`, `SelectAlbumById`). Access units are named for what the application wants (`RegisterAlbum`). That is the whole of the distinction.
**5.9. Every unit gets a `Result` type alias**, named after the unit: `type RegisterAlbumResult = Either RegisterAlbumError Domain.AlbumId`. It gives one stable name to write in signatures and in documentation, and it keeps `Either` out of every caller's type.
**5.10. Generated and hand-written code share one shape and one namespace, and generated code is never edited.** A hand-written statement looks exactly like a generated one. If you hand-edit a generated module the system dies at the first regeneration.
---
## 6. The class family
Four classes, in `hasql-mapping`. Each is named after the Hasql construct it produces, each method is named after that construct, and each carries a `Result` associated type except `IsScalar`.
```haskell
class IsScalar a where
encoder :: Encoders.Value a
decoder :: Decoders.Value a
class IsStatement a where
type Result a
statement :: Statement a (Result a)
class IsTransaction a where
type Result a
isolation :: IsolationLevel
isolation = Serializable
mode :: Mode
mode = Write
transaction :: a -> Transaction (Result a)
class IsSession a where
type Result a
session :: a -> Session (Result a)
```
**6.1. What the classes buy.** A uniform entry point per construct, the parameter record killing positional-argument bugs, and — most valuable — an **enumerable API**: the set of instances *is* the layer's contract, discoverable by grepping for `instance IsSession`.
Note what they do *not* buy, so the reasoning is not overextended. They are not required for instrumentation, which a wrapping combinator does at least as well and more visibly. And reification does not enable roundtrip property testing at the statement level or above (§9.2).
**6.2. `isolation` and `mode` are properties of the transaction, not of the call site.** Whether an operation needs `Serializable` is a fact about what it does, and a caller can forget it. `isolation` and `mode` use an ambiguous type variable, so `AllowAmbiguousTypes` is needed where the class is defined and `isolation @a` appears inside the runner, which you write once.
**6.3. The defaults are `Serializable` and `Write` deliberately.** The safe case is free and the relaxed case is explicit and reviewable. The opposite defaults would make every under-isolated transaction invisible.
**6.4. A composite transaction declares the join of its components.** `Transaction` is a `Monad`, so transactions compose. Both properties form join-semilattices — `ReadCommitted < RepeatableRead < Serializable` and `Read < Write` — so the composite's requirement is derivable and the conservative answer is always correct.
**6.5. Retry policy is *not* a class member.** It has no such structure: the maximum attempts of a composite is neither the maximum nor the minimum of its parts, and backoff schedules do not join at all. It cannot be derived, so it is supplied at execution. See §8.
**6.6. Isolation and mode must not be overridable at the call site. Retry policy should be.** Isolation is a correctness property and a caller weakening it breaks the transaction. Retry policy is a liveness and operational property, and a caller capping attempts under load breaks nothing.
**6.7. Each operation is represented at exactly one construct — the one whose capability it needs.** Its lower-construct building blocks are not given class instances.
*Rationale.* Access's `Sessions` typically exist to add SQLSTATE mapping to a transaction — same operation, same parameters. Without this rule that operation carries two instances, `session` and `transaction` both typecheck on it, and one silently throws where the other returns a typed error. Under the rule, the transaction beneath a session is a plain unexported `Transaction` value.
**6.8. Combinator-shaped units stay plain functions.** A transaction or session taking a callback, another transaction, or a fold cannot be indexed by a first-order parameter record. Streaming and cursor traversal are the common cases. Exclude them from the classes rather than contorting the classes around them.
---
## 7. Errors
Three channels, kept distinct because they have different audiences and different correct responses.
**7.1. Domain outcomes live in `Result`, produced with `condemn`.** `Transaction` has no error channel, and `condemn` exists precisely to mark a transaction for rollback *while still returning a value* — the rollback runs and the `Left` still reaches the caller.
```haskell
abort :: e -> Transaction (Either e a)
abort e = condemn $> Left e
```
Define a helper like this. `condemn` and the `Left` must always travel together, and a helper is what stops them drifting apart.
**7.2. SQLSTATE-derived errors are mapped at the session, where `catchError` exists.** `ServerError`'s first field is the five-character SQLSTATE, so a unique violation is `"23505"`.
**7.3. Prefer insert-and-catch over check-then-insert.** Check-then-insert needs `Serializable` to be correct and costs a round trip. Insert-and-catch is correct at any isolation level in one round trip. The structural consequence must be stated: such an operation's failure is not representable in its transaction's `Result`, so the typed error lives in the session above. This is a real limit of `IsTransaction` and the honest thing is to name it.
**7.4. Integrity violations go through `refineResult`, not through a domain error type.** A failed refinement means the database holds a value your domain says is impossible. That is a data-integrity violation, not a business outcome — a 5xx and an alert, not a 4xx and a message. Collapsing it into the domain error type would let a caller "handle" database corruption as a normal branch.
```haskell
refineResult :: (a -> Either Text b) -> Statement params a -> Statement params b
```
Because it transforms a `Statement` rather than a decoder, Access applies it to a Mapping statement without touching Mapping. The failure surfaces as `UnexpectedResultStatementError` inside `StatementSessionError`, which `Pool.use` returns as a `Left`, handled at the application edge alongside connection and decode errors. No wrapper type and no exception is needed, and the two costs — the whole result aborts, the message is `Text` — are correct for a failure nobody can handle locally.
Be aware that "impossible" is a claim about the world that is often false. Legacy rows, other writers and half-applied migrations all produce it. If integrity failures turn out to be routine in your system, having made them exceptional converts graceful degradation into outages.
**7.5. Never perform domain conversion inside a Mapping codec.** This is the one way the Mapping layer can be corrupted, and it is tempting because it saves a conversion function. It inverts the dependency (§4.1), aborts whole result sets (§4.2), erases typed errors (§4.3) and narrows test coverage (§4.4).
**7.6. One in-codec refinement is legitimate**: schema-level invariants the Mapping layer can state on its own, such as "this column is nullable in the table but this query's `WHERE` clause guarantees it is not". That is domain-free and belongs in Mapping.
---
## 8. Transactions in practice
**8.1. Retry fires only on SQLSTATE `40001` (serialization failure) and `40P01` (deadlock detected).** Nothing else, and in particular not unique violations.
**8.2. `hasql-transaction`'s retry is unbounded and has no backoff.** `inRetryingTransaction` is a `fix` loop. A `Serializable` transaction under sustained contention can spin indefinitely, holding a connection and never surfacing an error. The library exposes only a boolean — `transaction` retries, `transactionNoRetry` does not.
**8.3. A bounded policy currently requires your own runner** over `transactionNoRetry` that matches `40001` and `40P01` itself. See [hasql-transaction#25](https://github.com/nikita-volkov/hasql-transaction/issues/25), which proposes closing this in the library, where the matching logic already lives.
---
## 9. Testing
Real PostgreSQL throughout, via testcontainers. Mocked codecs test nothing.
**9.1. Mapping `Types`: roundtrip properties via `select $1`.** Encode an arbitrary value, decode it back, compare. One spec module per type, mirroring the source tree. **This is the only test in the system that verifies codec fidelity**, and it is as mechanically generatable as the smoke tests.
**9.2. Mapping `Statements`: smoke tests.** For arbitrary parameters, the statement executes and its result decodes without error. Call these what they are — they exercise encoders against a real server's type checking across the generated range, and they are not roundtrip properties, because nothing compares an encoded value with a decoded one.
**9.3. Access: example-based tests through the public surface.** One test per session per failure path, asserting outcomes and, for anything atomic, that nothing was committed. Property tests are the wrong tool because the interesting property is atomicity, not roundtripping.
**9.4. Access statements need no tests of their own.** Mapping's already exercise the SQL and the codecs, and generating `Arbitrary` for domain parameters would narrow coverage rather than add any.
**9.5. Testing only through the surface has one cost**: Access conversions are pure functions, so `Format.fromAlbumFormat . Format.toAlbumFormat == id` would be a cheap property, and with the module internal you can only reach it through a session.
---
## 10. Packaging
**10.1. A separate package or sublibrary is recommended, not required.** A directory inside the application package with §3.1 upheld by convention and §5.2's greppability is a reasonable arrangement.
**10.2. The aggregator is the enforcement point.** Its export list is the public surface. `other-modules` still re-export through it, so hiding costs nothing.
**10.3. Reach for an internal sublibrary only when Access is a separately-consumed package** and you want §5.4 enforced rather than advisory. Within a single package there is no consumer to govern and the aggregator plus review gives the same discipline for free.
---
## 11. Appendix: worked example
Schema-mirroring layer, generated or hand-written in the same shape:
```haskell
-- MySpace.MusicCatalogue.Mapping.Types.AlbumFormat
data AlbumFormat = VinylAlbumFormat | CdAlbumFormat | CassetteAlbumFormat
deriving stock (Show, Eq, Ord, Enum, Bounded)
instance IsScalar AlbumFormat where
encoder = Encoders.enum (Just "public") "album_format" \case
VinylAlbumFormat -> "Vinyl"
CdAlbumFormat -> "CD"
CassetteAlbumFormat -> "Cassette"
decoder = Decoders.enum (Just "public") "album_format" \case
"Vinyl" -> Just VinylAlbumFormat
"CD" -> Just CdAlbumFormat
"Cassette" -> Just CassetteAlbumFormat
_ -> Nothing
```
```haskell
-- MySpace.MusicCatalogue.Mapping.Statements.InsertAlbum
data InsertAlbum = InsertAlbum
{ name :: Text,
released :: PostgresqlTypes.Date,
format :: AlbumFormat
}
deriving stock (Eq, Show)
type InsertAlbumResult = InsertAlbumResultRow
newtype InsertAlbumResultRow = InsertAlbumResultRow {id :: Int64}
deriving stock (Show, Eq)
instance IsStatement InsertAlbum where
type Result InsertAlbum = InsertAlbumResult
statement = Statement.preparable sql encoder decoder
where
sql = "insert into album (name, released, format) values ($1, $2, $3) returning id"
encoder = mconcat
[ (.name) >$< Encoders.param (Encoders.nonNullable IsScalar.encoder),
(.released) >$< Encoders.param (Encoders.nonNullable IsScalar.encoder),
(.format) >$< Encoders.param (Encoders.nonNullable IsScalar.encoder)
]
decoder = Decoders.singleRow do
id <- Decoders.column (Decoders.nonNullable IsScalar.decoder)
pure InsertAlbumResultRow {..}
```
Conversions, keyed by domain type, functions named for the PostgreSQL type (§5.5):
```haskell
-- MySpace.MusicCatalogue.Access.Types.Format
toAlbumFormat :: Domain.Format -> Mapping.Types.AlbumFormat
fromAlbumFormat :: Mapping.Types.AlbumFormat -> Domain.Format
```
Adapted statement — no new SQL (§3.4, §5.7):
```haskell
-- MySpace.MusicCatalogue.Access.Statements.InsertAlbum
data InsertAlbum = InsertAlbum {album :: Domain.NewAlbum}
instance IsStatement InsertAlbum where
type Result InsertAlbum = Domain.AlbumId
statement =
refineResult
(AlbumId.fromInt64 . (.id))
(lmap ((.album) >>> NewAlbum.toInsertAlbum) Mapping.Statements.statement)
```
Transaction — a reusable atomic building block, with isolation bound to the operation (§6.2). It inserts only fresh rows with generated ids, so it has no anomaly exposure and `ReadCommitted` is locally determinable:
```haskell
-- MySpace.MusicCatalogue.Access.Transactions.InsertAlbumWithTracks
data InsertAlbumWithTracks = InsertAlbumWithTracks
{album :: Domain.NewAlbum, tracks :: [Domain.NewTrack]}
type InsertAlbumWithTracksResult = Domain.AlbumId
instance IsTransaction InsertAlbumWithTracks where
type Result InsertAlbumWithTracks = InsertAlbumWithTracksResult
isolation = ReadCommitted
transaction params = do
albumId <- Transaction.statement (InsertAlbum params.album) IsStatement.statement
for_ params.tracks \track ->
Transaction.statement (InsertTrack albumId track) IsStatement.statement
pure albumId
```
Session — the public surface, where the SQLSTATE becomes a typed outcome (§7.2, §7.3):
```haskell
-- MySpace.MusicCatalogue.Access.Sessions.RegisterAlbum
data RegisterAlbum = RegisterAlbum {album :: Domain.NewAlbum, tracks :: [Domain.NewTrack]}
data RegisterAlbumError = AlbumAlreadyExists
type RegisterAlbumResult = Either RegisterAlbumError Domain.AlbumId
instance IsSession RegisterAlbum where
type Result RegisterAlbum = RegisterAlbumResult
session params =
catchingSqlState "23505" AlbumAlreadyExists
$ runTransaction (InsertAlbumWithTracks params.album params.tracks)
```
Note that the session and the transaction are *different operations* with different names — the transaction is a reusable atomic unit, the session is the use case that maps its unique violation to a domain outcome. Had this session been the only way to reach that transaction, the transaction would carry no `IsTransaction` instance and would be a plain unexported `Transaction` value in the session's own module (§6.7).