The youteacher_content module keeps its business rules where they can't be skipped: inside the domain aggregates themselves. There are three of them — Post, Category, Page — plus one shared value object, Slug. Every aggregate is immutable: each mutating method (update, publish, moveTo, …) returns a brand-new instance rather than editing in place, and the only two ways to obtain one are create (a fresh entity, with defaults) and reconstitute (rebuilt from persisted state).
Slug — the shared identifier
Slug is a value object shared by posts and categories. It has two doors in.
Slug.create(value) is the strict one: it lowercases and trims, then insists the result match ^[\da-z]+(?:-[\da-z]+)*$ — lowercase letters, digits, and single hyphens between segments, nothing else — and caps the length at 200 characters. Anything outside that throws.
Slug.fromTitle(title) is the forgiving one, used whenever a post or category is created or renamed. It lowercases, strips every character that isn't an ASCII letter, digit, space, or hyphen, collapses runs of spaces into single hyphens, collapses runs of hyphens, and trims stray hyphens off the ends. The interesting edge is a pure-CJK or pure-symbol title: after the ASCII-only filter it slugifies to an empty string. Rather than fail the request, fromTitle falls back to a generated opaque id of the shape post- followed by a ten-character nanoid drawn from the same lowercase-alphanumeric alphabet — so it still passes the strict regex. The human-readable CJK title stays visible in the UI; only the URL id is opaque. The final slug is truncated to 200 characters.
Post — the blog aggregate root
A Post carries its title, content with a contentFormat (blocknote — the default — or html or markdown), an excerpt, an optional featured image, an optional categoryId, SEO meta fields, denormalized author fields (authorId, authorName, authorAvatarUrl), and a list of attachments. Its slug is auto-generated from the title via Slug.fromTitle, both on create and again on update whenever the title changes.
Draft / publish, with scheduling. A freshly created post is always a draft with a null publishedAt. publish() with no argument publishes immediately (publishedAt = now) and is idempotent — re-publishing an already-published post returns it unchanged, keeping the original date. publish(futureDate) is the scheduling path: the post is persisted with status published but a future publishedAt. The isPublished getter encodes the visibility rule — it is true only when status is published and publishedAt is non-null and publishedAt <= now — so a scheduled post reads as "published" in storage yet stays publicly invisible until its date arrives. An explicit date always applies, which is how a live post gets rescheduled or a scheduled one is brought forward. unpublish() returns the post to draft while preserving publishedAt, and is idempotent for a post already in draft.
Alias URLs. A post can carry a second, human-chosen URL alongside its slug. setAlias trims the input and rejects an empty string or anything shorter than three characters; removeAlias clears it back to null.
Attachments, capped. Attachments are entities living inside the Post aggregate. addAttachment enforces a hard ceiling of 10 attachments per post and throws once that is reached. removeAttachment throws if the id isn't present rather than silently doing nothing.
Attachment — the entity with the size guard
An Attachment records a filename, url, mime type, and size. Its create factory is where the file rules live: filename and url must be non-empty after trimming, size must be positive, and size must not exceed 10MB (10 * 1024 * 1024). reconstitute rebuilds one from storage without re-running the guards.
Category — nesting to depth 3
Categories form a tree with a hard MAX_DEPTH of 3. Each category stores not just its parentId but two materialized ancestry arrays: path (ancestor category ids) and slugPath (ancestor slugs), plus a numeric depth. fullSlugPath joins the slugPath with the category's own slug into a single a/b/c string; isRoot is simply having no parent.
The depth ceiling is checked on both create and moveTo: a parent whose own depth is already at MAX_DEPTH - 1 cannot take a child, because that child would sit at the forbidden level. moveTo(newParent) adds a second guard — it refuses to move a category underneath one of its own descendants (detected by checking whether the target parent's path already contains this category's id), which is what would otherwise create a cycle. Both create and moveTo recompute path, slugPath, and depth from the parent. detach() is just moveTo(null) — promoting a category back to a root with empty ancestry arrays and depth 0.
Page — the standalone Puck page
Page is the simplest aggregate. It holds a slug (a plain string supplied by the caller, not the Slug value object and not auto-generated), a title, and puckData — an opaque Record holding the visual page-builder layout, or null. It has the same draft / published status field but no scheduling: publish() and unpublish() flip the status with nothing date-aware behind them, and isPublished is a plain status check. This is the deliberate difference from Post — a Page is authored layout, not a dated blog entry.
Why it's shaped this way
The invariants that matter — a slug is always URL-safe, a post is never publicly visible before its date, a category tree never exceeds three levels or forms a cycle, an attachment is never oversized — all live inside the aggregates, enforced at construction and mutation. Nothing upstream has to remember to check them, because the only way to build these objects routes through the guards.