---
layout: 'page'
uri: '/skills/gk-entities'
position: 10
slug: 'skills-gk-entities'
parent: 'skills-domain'
navTitle: 'gk-entities'
title: 'GK — Entities & Value Objects'
description: 'Doménové modelování — entity (db tagy pro sqlx) a value objects, které brání vzniku nevalidního objektu. Use when přidáváš nový doménový typ (User, Token, Run…), value object s validací nebo factory funkci, nebo řešíš „kam patří validace".'
name: 'gk-entities'
---

# GK — Entities & Value Objects

Jak se v gokicku modeluje doména: **entity** (objekty s identitou, mapované na DB)
a **value objects** (typy, které se nedají vyrobit v nevalidním stavu).

## What & when
- Sáhni sem, když přidáváš nový doménový typ (entitu jako `User`/`Run`, nebo
  value object jako `Nickname`), píšeš factory funkci (`NewUser`), nebo řešíš
  „kde má žít validace formátu vs. business pravidlo".
- NEtýká se: repozitářů (to je infrastruktura — `/gk-repositories`), command/query
  handlerů a permissions (`/gk-commands`, `/gk-queries`), ani doménových
  událostí na bus (`/gk-domain-events`).

## For non-tech / juniors
**Entita** je doménový objekt, který má identitu (ID) a něco v životě reprezentuje
— uživatel, refresh token, úloha na pozadí. Žije v DB jako řádek tabulky.

**Value object** je malý typ, který reprezentuje hodnotu a sám si hlídá, že je
platná. Místo holého `string` pro přezdívku máš typ `Nickname`, který vyrobíš
jen přes `NewNickname(...)`. Když je vstup špatný (prázdná přezdívka, moc dlouhá),
konstruktor vrátí chybu a žádný objekt nevznikne. Výhoda: jakmile někde držíš
`Nickname`, máš jistotu, že je validní — nemusí se to znovu kontrolovat. „Nelze
postavit objekt v neplatném stavu."

Analogie: value object je formulářové políčko, které tě nepustí dál, dokud
nevyplníš správnou hodnotu. Entita je celý vyplněný formulář s razítkem (ID).

## How it works
**Bounded contexts** — každá entita má vlastní balíček pod `app/domain/`:
`domain/user/`, `domain/token/`, `domain/run/`, `domain/tenant/`. Mezi kontexty se **nesmí
importovat** (`user/` nesmí znát `token/`); sdílené typy žijí v `domain/shared/`.

**Entity** (`app/domain/user/user.go`, `domain/token/refresh_token.go`,
`domain/run/run.go`):
- Struct má `db:"..."` tagy — `sqlx` podle nich automaticky scanuje řádky DB do
  struktury. Příklad: `Nickname string \`db:"nickname"\``.
- ID je `string` (UUIDv7). `User`/`Run` (i `Tenant`) generují
  `uuid.Must(uuid.NewV7()).String()`; `RefreshToken` (factory `NewRefreshToken`)
  používá `uuid.New().String()`.
- Entita nemá metody se side-effecty (žádné `Save`/`Load`) — to dělá repository.
- Nullable časové sloupce: `*time.Time` (nil = unset) napříč VŠEMI kontexty —
  `Run.CompletedAt`, `RefreshToken.UsedAt` (marker theft detection),
  `User.LockedUntil` / `LastLoginAt` / `LastFailedLoginAt`. ncruces driver skenuje
  nullable DATETIME TEXT sloupec do `*time.Time` bez custom typu (žádný
  `sql.NullTime`); zápisy stampují ms přesnost (`strftime %f`), round-trip ověřen
  pod zátěží (sqlite_loadtest).

**Factory funkce** (`NewUser`, `NewRun`):
- Přijímají **value objects, ne raw stringy** — `NewUser(nickname Nickname,
  passwordHash string, email Email, role Role, tenantID string)`. Když se caller dostal až k
  factory, data jsou validní. `tenantID` je povinný (born-scoped invariant multitenancy,
  vynucený `zz_bornscoped_test.go`) — single-tenant caller předá `shared.DefaultTenantID`. Když se caller dostal až k
  factory, data jsou validní.
- `passwordHash` je odvozený stav (produkt `PasswordHasher`), ne value object —
  raw heslo se validuje přes `Password` VO těsně před hashováním.

**Value objects** (`domain/user/nickname.go`, `role.go`, `email.go`, `password.go`):
- Typ je `type Nickname string` + konstruktor `func NewNickname(s string)
  (Nickname, error)`.
- Při nevalidním vstupu vrací `*shared.ValidationError{Field, Message}` —
  `Field` se na FE mapuje na konkrétní políčko (viz `/gk-frontend-forms`).
- Konkrétně:
  - `Nickname`: povinný, max 50 znaků.
  - `Role`: enum `RoleSuperAdmin`/`RoleAdmin`/`RoleUser` (konverze kanonických
    `shared.Role*` konstant), jiná hodnota → chyba. `Role.IsSuperAdmin()` hlídá,
    že admin API superadmina nikdy nepřidělí (superadmin vzniká jen seedem/CLI).
  - `Email`: **nepovinný** — prázdný řetězec projde; jinak max 254 znaků a musí
    obsahovat `@`. Striktnější (regex/MX) schválně ne.
  - `Password`: validuje **raw** heslo před hashem — povinné, min 8 znaků (runy),
    max 128 **bajtů** (záměrně bajtový anti-DoS strop vstupu hasheru). Pro cesty,
    které hashují hned, existuje `user.HashNewPassword(raw, hasher)` — validace
    a hash v jednom volání.

## Recipe

### Recipe: přidat value object
1. Nový soubor v `app/domain/<context>/<name>.go`, `type X string` (nebo jiný
   primitiv).
2. `func NewX(s string) (X, error)` — validuj formát/délku/povinnost; při chybě
   vrať `&shared.ValidationError{Field: "x", Message: "…"}`.
3. Použij ho v factory a v command handleru (`NewX` se volá tam, kde přijde raw
   vstup od uživatele).

### Recipe: přidat entitu (nový bounded context)
1. Nový balíček `app/domain/<context>/<entity>.go` — struct s `db:"..."` tagy,
   ID jako `string`.
2. Factory `New<Entity>(...)` přijímající value objects, ne raw stringy.
3. Repository **interface** ve stejném balíčku (`repository.go`) — viz
   `/gk-repositories` pro implementaci a `.go-arch-lint.yml` (nový context = nová
   `domain_<context>` komponenta + `mayDependOn` granty).

## Invariants & pitfalls
- **Validace formátu/povinnosti žije ve value objektu**, ne v handleru. Business
  pravidla s I/O (unique nickname — repo lookup) žijí v command handleru. SQL
  constraints (`UNIQUE`, `CHECK`) jsou jen záchranná síť.
- **Factory bere value objects, ne raw stringy** — jinak může vzniknout entita z
  nevaliddních dat.
- **Žádné cross-context importy** — `domain/user/` nesmí importovat `domain/token/`.
  Sdílené typy → `domain/shared/`.
- **Value object vrací `*shared.ValidationError`** (ne `errors.New`) — jen tak se
  na FE chyba namapuje na správné políčko (metoda `HandleError` injektovaného
  `*response.Responder` → 400).
- **Entita nemá I/O metody** — perzistence patří do repository.
- **`LoginCommand` NEvaliduje heslo přes `Password` VO** — jen porovnává se
  stored hashem. Validace pravidel při loginu by zamkla existující účty po změně
  pravidel. `Password` se používá všude, kde vzniká NOVÉ heslo — `CreateUserCommand`,
  `ChangePasswordCommand`, platformní `CreateSuperAdminCommand`, admin/platform
  update (`userwrite.Update` přes `user.HashNewPassword`) a seeder/CLI.
- **Not-found konvence repozitářů je jednotná:** `FindByID` i `FindByNickname` vrací
  `nil, nil` (nenalezeno není chyba) — stejně jako porty token/run/tenant. Nil entita
  JE signál not-found; error znamená skutečné (přechodné) selhání. Každý caller si
  nil namapuje na vlastní odpověď (400 pro admina editujícího stale id, 401/force-logout
  pro zmizelého přihlášeného uživatele).

## Related
- Sousední skills: `/gk-repositories` (repository implementace, `r.Conn(ctx)`),
  `/gk-commands` + `/gk-queries` (handlery, permissions, kde žijí business
  pravidla), `/gk-frontend-forms` (mapování `ValidationError.Field` na FE políčka),
  `/gk-domain-events` (doménové události jako `UserCreated`).
- Kód: `app/domain/user/` (`user.go`, `nickname.go`, `role.go`, `email.go`,
  `password.go`, `user_created.go`, `repository.go`), `app/domain/token/`
  (`refresh_token.go`, `repository.go`), `app/domain/run/` (`run.go`,
  `repository.go`), `app/domain/tenant/` (`tenant.go`, `name.go` — `Name` je
  value object, `repository.go`), `app/domain/shared/` (`ValidationError`)

---

[← Doména](/skills/domain.md) | [gk-domain-events →](/skills/gk-domain-events.md)