[{"data":1,"prerenderedAt":422},["ShallowReactive",2],{"\u002Fpost\u002Fone-post-many-forums":3},{"id":4,"title":5,"body":6,"createdAt":413,"description":414,"extension":415,"meta":416,"navigation":417,"path":418,"seo":419,"stem":420,"__hash__":421},"posts\u002Fpost\u002Fone-post-many-forums.md","One Post, Many Forums: Modeling Context Without Duplicating Content",{"type":7,"value":8,"toc":404},"minimark",[9,16,19,22,25,28,34,42,45,50,53,56,59,75,78,81,84,94,108,114,120,124,127,139,142,148,151,154,157,163,166,170,173,176,179,182,188,191,194,197,216,219,222,226,229,232,235,238,242,245,248,251,275,282,344,347,350,353,357,360,366,369,372,375,378,382,385,388,394,397,400],[10,11,12],"p",{},[13,14,15],"em",{},"How I separated canonical content from community-specific context in Neo4j",[10,17,18],{},"One of Multiforum's defining features sounds simple when described from the user's point of view: write one post and publish it in several forums.",[10,20,21],{},"The difficult question is what “one post” means after that happens.",[10,23,24],{},"Should every forum receive its own copy? If I edit the title later, should all of those copies change? Do votes and comments belong to the shared post or to the forum where someone encountered it? What happens when one forum removes the post but another leaves it up? And what if two forums require completely different metadata before accepting the same content?",[10,26,27],{},"Those questions led me to a distinction that now shapes much of Multiforum's graph model:",[29,30,31],"blockquote",{},[10,32,33],{},"A post is the canonical content. A submission is that post's presence in one particular forum.",[10,35,36,37,41],{},"I often describe the feature as cross-posting, but that term can also mean creating a second post that links back to an original. The design in this article is more precisely ",[38,39,40],"strong",{},"multi-forum publishing",": one underlying post connected to several forum-specific submissions.",[10,43,44],{},"The distinction added indirection to the database, API, and frontend. It also gave me a place to put each forum's votes, comments, moderation state, labels, subscriptions, and, most recently, post flairs without duplicating the underlying post.",[46,47,49],"h2",{"id":48},"the-obvious-model-was-not-the-right-model","The obvious model was not the right model",[10,51,52],{},"The simplest implementation would have created a separate post in every selected forum. Each copy could have its own comments, votes, and metadata, and every forum query would be straightforward.",[10,54,55],{},"It would also create several new problems.",[10,57,58],{},"If I published a typo to five forums, editing it would require finding and updating five posts. A failure in the middle could leave them with different text. Revision history would fragment. Attachments and albums could diverge. A bookmark might refer to one copy rather than to the work as a whole. Features that operate on canonical content would have to decide which copy was authoritative.",[10,60,61,62,66,67,70,71,74],{},"The opposite approach—one post with direct relationships to several forums—would preserve a canonical identity, but it would leave nowhere natural for forum-specific state. Neo4j relationships can store properties, so I could have put values such as ",[63,64,65],"code",{},"archived"," or ",[63,68,69],{},"createdAt"," on a ",[63,72,73],{},"POSTED_IN"," relationship. That works while the connection is only a connection.",[10,76,77],{},"In Multiforum, it quickly became more than that.",[10,79,80],{},"A post's presence in a forum can be voted on, commented on, archived, locked, reported, marked as answered, subscribed to, labeled, and processed by forum-specific plugin pipelines. Those behaviors have their own relationships and histories. Once other entities need to point at the connection, treating it as an anonymous edge becomes awkward.",[10,82,83],{},"I modeled the connection as a node instead.",[85,86,92],"pre",{"className":87,"code":89,"language":90,"meta":91},[88],"language-text","                         ┌──────────────────────┐\n                         │      Discussion      │\n                         │ title, body, author  │\n                         │ revisions, files     │\n                         └──────────▲───────────┘\n                                    │\n                              POSTED_IN_CHANNEL\n                                    │\n                         ┌───────────┴──────────┐\n                         │  DiscussionChannel   │\n                         │ votes, comments,     │\n                         │ moderation, flairs   │\n                         └───────────┬──────────┘\n                                    │\n                              POSTED_IN_CHANNEL\n                                    │\n                         ┌───────────▼──────────┐\n                         │       Channel        │\n                         │ forum configuration  │\n                         └──────────────────────┘\n","text","",[63,93,89],{"__ignoreMap":91},[10,95,96,99,100,103,104,107],{},[63,97,98],{},"Discussion"," is the canonical post. ",[63,101,102],{},"Channel"," is a forum. ",[63,105,106],{},"DiscussionChannel"," is one submission of that post to that forum.",[10,109,110,111,113],{},"In a relational database, ",[63,112,106],{}," would look like a join table that had grown into a domain object. In Neo4j, making it a node means users, comments, moderation issues, notification subscriptions, and other nodes can connect directly to the submission they concern.",[10,115,116,117,119],{},"The model makes ownership explicit. Editing the title or body changes the canonical discussion everywhere. Archiving the submission in one forum changes only that ",[63,118,106],{},". Comments made in a forum remain attached to the forum-specific conversation rather than becoming a sitewide thread detached from its context.",[46,121,123],{"id":122},"identity-required-deliberate-duplication","Identity required deliberate duplication",[10,125,126],{},"The connector node introduced an invariant: a discussion should have at most one submission in a given forum.",[10,128,129,130,132,133,135,136,138],{},"Conceptually, the pair of relationships identifies it. A ",[63,131,106],{}," points to one ",[63,134,98],{}," and one ",[63,137,102],{},". Neo4j cannot enforce a uniqueness constraint based on the endpoints of those relationships, however. Constraints operate on node properties.",[10,140,141],{},"I therefore store two deliberately denormalized values on every connector:",[85,143,146],{"className":144,"code":145,"language":90,"meta":91},[88],"discussionId\nchannelUniqueName\n",[63,147,145],{"__ignoreMap":91},[10,149,150],{},"A composite node-key constraint guarantees that the pair is unique.",[10,152,153],{},"That decision is a tradeoff. The IDs repeat information that is already present in the graph, and I have to keep the properties consistent with the relationships. In exchange, the database—not just application code—can reject duplicate submissions. The same properties also make it possible to find a connector efficiently without first traversing from both endpoints.",[10,155,156],{},"The constraint affected the API design too. Multiforum uses Neo4j GraphQL to generate much of its routine CRUD API, but the generated create mutation could not perform this workflow cleanly. I do not know the discussion ID until after the discussion exists, and I need that ID to create the uniquely constrained connector nodes.",[10,158,159,160,162],{},"I wrote a custom resolver that creates the canonical discussion first and then creates one ",[63,161,106],{}," for each selected forum. That resolver also creates the author's initial upvote, applies notification preferences, attaches forum metadata, and triggers forum-specific plugin pipelines.",[10,164,165],{},"This is one of the recurring costs of a richer domain model: operations that look like one action in the interface may cross several nodes and invariants in the database. The benefit is that the complexity lives in an explicit application service instead of being distributed across clients.",[46,167,169],{"id":168},"flairs-tested-the-boundary","Flairs tested the boundary",[10,171,172],{},"The recently added flair system gave the model a useful stress test.",[10,174,175],{},"A flair is a forum-defined category such as “Question,” “Showcase,” or “Needs Help.” A forum owns its flair vocabulary, chooses the display order and colors, and decides whether selecting at least one flair is required.",[10,177,178],{},"That metadata cannot belong directly to the canonical discussion. The same post might be a “Question” in a technical support forum and a “Project” in a hobby forum. Neither category is globally true. Each describes how the post participates in one community.",[10,180,181],{},"The graph represents the two kinds of ownership separately:",[85,183,186],{"className":184,"code":185,"language":90,"meta":91},[88],"Channel ──owns──▶ DiscussionFlair\n\nDiscussionChannel ──selects──▶ DiscussionFlair\n",[63,187,185],{"__ignoreMap":91},[10,189,190],{},"The forum owns the available category. The submission owns the selection.",[10,192,193],{},"This meant I could add flairs without splitting a discussion into copies or changing what a discussion fundamentally represented. The connector node already described the exact scope where the new data belonged.",[10,195,196],{},"It also revealed that “add a flair field” was not an accurate description of the work. A valid flair selection depends on several entities and rules:",[198,199,200,204,207,210,213],"ul",{},[201,202,203],"li",{},"Every selection must refer to a forum receiving the discussion.",[201,205,206],{},"The selected flair must belong to that forum.",[201,208,209],{},"It must still be active when the mutation reaches the backend.",[201,211,212],{},"A forum that requires flair must receive at least one selection.",[201,214,215],{},"The request must not contain duplicate forums or duplicate flair IDs.",[10,217,218],{},"I enforce those rules on the server before creating the discussion. The frontend performs the same user-facing checks so it can explain what is missing immediately, but the backend remains authoritative. A client can be stale, buggy, or bypassed entirely. It is not enough for an ID to exist; it must be valid in the context where it is being used.",[10,220,221],{},"The distinction matters when two forums are selected at once. A flair ID from the first forum must not be accepted for the second, even if someone manually constructs the GraphQL request. Validation loads the active flair configuration for all selected forums, groups the submitted IDs by forum, and checks every group against its owner.",[46,223,225],{"id":224},"configuration-needs-a-history-too","Configuration needs a history too",[10,227,228],{},"Forum owners can rename, reorder, add, and retire flairs. I chose to archive removed flairs rather than delete them.",[10,230,231],{},"Deleting a flair would erase the meaning of old submissions or leave dangling references. Archiving it removes the option from new-post forms while allowing existing assignments to continue rendering. An archived name also no longer prevents a forum owner from creating a new active flair with the same display name.",[10,233,234],{},"That choice creates additional edit semantics. When an existing discussion is opened for editing, the form presents active selections and active choices. It should not silently overwrite untouched metadata merely because an old option is no longer available. The edit flow therefore distinguishes between a required selection, a selection the author changed, and an optional selection the author left alone.",[10,236,237],{},"This is a small example of a broader lesson: configuration data becomes historical data as soon as user-created records refer to it. “Delete” often means “stop offering this in the future,” not “pretend this never existed.”",[46,239,241],{"id":240},"one-post-created-a-dynamic-form-problem","One post created a dynamic form problem",[10,243,244],{},"Supporting flairs in a form scoped to one forum was relatively direct. The page loads that forum's configuration, displays its choices, and blocks submission if a required selection is missing.",[10,246,247],{},"Sitewide creation was more complicated. A person can select and deselect several forums while composing one post, and every selection can introduce a different set of requirements.",[10,249,250],{},"The frontend has to:",[252,253,254,257,260,263,266,269,272],"ol",{},[201,255,256],{},"Watch the current set of selected forums.",[201,258,259],{},"Load each forum's flair configuration independently.",[201,261,262],{},"Track loading and error state by forum rather than with one global boolean.",[201,264,265],{},"Render one picker for every forum that offers or requires flairs.",[201,267,268],{},"Prevent submission while any required configuration is unresolved.",[201,270,271],{},"Remove stored flair selections when their forum is deselected.",[201,273,274],{},"Produce a GraphQL input grouped by forum.",[10,276,277,278,281],{},"A single flat ",[63,279,280],{},"selectedFlairIds"," array would lose the ownership information. I store the form state as a map instead:",[85,283,287],{"className":284,"code":285,"language":286,"meta":91,"style":91},"language-ts shiki shiki-themes github-dark","selectedFlairIdsByChannel: {\n  gardening: ['question'],\n  woodworking: ['showcase', 'beginner']\n}\n","ts",[63,288,289,302,318,338],{"__ignoreMap":91},[290,291,294,298],"span",{"class":292,"line":293},"line",1,[290,295,297],{"class":296},"svObZ","selectedFlairIdsByChannel",[290,299,301],{"class":300},"s95oV",": {\n",[290,303,305,308,311,315],{"class":292,"line":304},2,[290,306,307],{"class":296},"  gardening",[290,309,310],{"class":300},": [",[290,312,314],{"class":313},"sU2Wk","'question'",[290,316,317],{"class":300},"],\n",[290,319,321,324,326,329,332,335],{"class":292,"line":320},3,[290,322,323],{"class":296},"  woodworking",[290,325,310],{"class":300},[290,327,328],{"class":313},"'showcase'",[290,330,331],{"class":300},", ",[290,333,334],{"class":313},"'beginner'",[290,336,337],{"class":300},"]\n",[290,339,341],{"class":292,"line":340},4,[290,342,343],{"class":300},"}\n",[10,345,346],{},"The shape mirrors the domain model. That makes the final mutation almost mechanical: transform each map entry into a forum name and its selected IDs.",[10,348,349],{},"Asynchronous loading introduced another edge case. A configuration request can finish after its forum has been deselected. If I accepted that late result unconditionally, stale configuration could reappear in the form. The composable checks that the forum is still selected before storing either the result or its error, and it prunes state whenever the selected set changes.",[10,351,352],{},"This is the frontend version of the same ownership rule. State is not valid merely because it was fetched successfully. It is valid only while the form context that requested it still exists.",[46,354,356],{"id":355},"what-the-design-made-easyand-what-it-made-harder","What the design made easy—and what it made harder",[10,358,359],{},"The connector-node model has paid for itself as Multiforum has grown.",[10,361,362,363,365],{},"It gives me one canonical post, one edit history, and one set of attachments. It also gives each forum an independent conversation, voting state, moderation lifecycle, notification subscriptions, labels, and flairs. Adding forum-scoped metadata usually means attaching another property or relationship to ",[63,364,106],{},", not inventing a synchronization system between duplicate posts.",[10,367,368],{},"The cost is visible throughout the application.",[10,370,371],{},"Queries need an extra traversal. GraphQL responses have an additional nesting level. Cache updates must locate the correct submission instead of treating a discussion as the list item. Routes contain both a forum context and a discussion ID. Create and edit mutations need custom coordination. The UI must repeatedly answer whether an action concerns the canonical work or one forum's submission.",[10,373,374],{},"There is also a transactional limitation in the current creation flow. I validate all flair requirements before inserting the discussion, which prevents invalid metadata from leaving behind a canonical post. But creating the discussion and attaching each forum submission are still separate database operations rather than one explicit transaction. A failure partway through a multi-forum publish could leave a discussion connected to only some of the selected forums.",[10,376,377],{},"The uniqueness constraint makes retrying safer, and the resolver already handles duplicate connector attempts, but full atomicity would be a worthwhile improvement. I would rather describe that boundary honestly than imply that a good model automatically solves every operational concern.",[46,379,381],{"id":380},"the-lesson-i-would-reuse","The lesson I would reuse",[10,383,384],{},"The most valuable decision was not choosing a graph database. It was separating content from context.",[10,386,387],{},"When one object appears in several places, it is tempting either to copy it or to treat every appearance as a thin pointer. Neither extreme was right for Multiforum. The content needed one identity, while each appearance accumulated enough behavior to deserve an identity of its own.",[10,389,390,391,393],{},"The flair system validated that decision. Years after I introduced ",[63,392,106],{},", I was able to give every forum its own required taxonomy without duplicating posts or making local categories globally authoritative. The original abstraction did not make the feature free—the backend validation and multi-forum form were substantial—but it gave the complexity one coherent place to live.",[10,395,396],{},"That is the tradeoff I would make again: accept an extra domain object and the indirection it creates when the alternative is putting data at the wrong scope.",[10,398,399],{},"One post can belong to many communities. Its words can remain singular while its meaning, conversation, and obligations change with the context in which it appears.",[401,402,403],"style",{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":91,"searchDepth":304,"depth":304,"links":405},[406,407,408,409,410,411,412],{"id":48,"depth":304,"text":49},{"id":122,"depth":304,"text":123},{"id":168,"depth":304,"text":169},{"id":224,"depth":304,"text":225},{"id":240,"depth":304,"text":241},{"id":355,"depth":304,"text":356},{"id":380,"depth":304,"text":381},"2026-08-03","How I modeled one canonical post with forum-specific submissions, and why Neo4j connector nodes made cross-posting, moderation, and required flair metadata possible.","md",{},true,"\u002Fpost\u002Fone-post-many-forums",{"title":5,"description":414},"post\u002Fone-post-many-forums","BZonLAbjvZDeDVGYFqnWtvquusfuYiQhnpWOJT2gDn4",1785745897175]