Skip to content

Latest commit

 

History

History
541 lines (448 loc) · 18.2 KB

File metadata and controls

541 lines (448 loc) · 18.2 KB

Architecture

Overview

ContextFlow is a browser-based React application for mapping bounded contexts, their strategic relationships, and their code ownership. It presents three synchronized projections of the same model: Value Stream View, Distillation View, and Strategic View.

The core of the app is:

  • A canvas (React Flow) that renders nodes/edges/groups and allows drag interactions
  • Side panels for details and editing
  • Import/export/autosave around a single Project data structure

This document defines how we build it.


Tech stack decisions

  • Language: TypeScript
  • UI framework: React
  • Runtime/build: Vite (simple local dev, runs in browser)
  • Styling: Tailwind CSS
  • UI primitives: shadcn/ui (Radix-based, accessible, modern aesthetic)
  • Icons: lucide-react
  • Canvas / graph rendering: React Flow
    • Handles pan/zoom, nodes, edges, selection
  • Animation: Framer Motion
    • Smoothly animate node positions when switching between views
  • State: Zustand store for editor state (read-only projection of Yjs)
  • Persistence: Yjs + Cloudflare Durable Objects for real-time cloud sync
  • Collaboration: Yjs CRDT for conflict-free real-time editing
  • Backend: Cloudflare Workers with y-partyserver for WebSocket sync

Data model

export type IssueSeverity = 'info' | 'warning' | 'critical';

export interface Issue {
  id: string;
  title: string;
  description?: string;
  severity: IssueSeverity;
}

export type ContextOwnership = 'ours' | 'internal' | 'external';

export interface Project {
  id: string;
  name: string;
  version?: number;
  isBuiltIn?: boolean;
  createdAt?: string;
  updatedAt?: string;

  contexts: BoundedContext[];
  relationships: Relationship[];
  repos: Repo[];
  people: Person[];
  teams: Team[];
  groups: Group[];
  users: User[];
  userNeeds: UserNeed[];
  userNeedConnections: UserNeedConnection[];
  needContextConnections: NeedContextConnection[];

  viewConfig: {
    flowStages: FlowStageMarker[];
  };

  temporal?: {
    enabled: boolean;
    keyframes: TemporalKeyframe[];
  };
}

export interface BoundedContext {
  id: string;
  name: string;
  purpose?: string;

  strategicClassification?: 'core' | 'supporting' | 'generic';
  ownership?: ContextOwnership;

  boundaryIntegrity?: 'strong' | 'moderate' | 'weak';
  boundaryNotes?: string;

  positions: {
    strategic: { x: number };               // Strategic View horizontal (0..100)
    flow: { x: number };                    // Value Stream View horizontal (0..100)
    distillation: { x: number; y: number }; // Distillation View 2D position (0..100)
    shared: { y: number };                  // vertical (0..100), shared across Flow/Strategic views
  };

  evolutionStage: 'genesis' | 'custom-built' | 'product/rental' | 'commodity/utility';

  codeSize?: {
    loc?: number;
    bucket?: 'tiny' | 'small' | 'medium' | 'large' | 'huge';
  };

  isLegacy?: boolean;
  isBigBallOfMud?: boolean;

  businessModelRole?: 'revenue-generator' | 'engagement-creator' | 'compliance-enforcer' | 'cost-reduction';

  notes?: string;
  issues?: Issue[];
  teamId?: string; // direct team assignment (orthogonal to repo ownership)
}

export interface Relationship {
  id: string;

  // Arrow points to upstream (the one with more power / defines language)
  fromContextId: string; // downstream / dependent
  toContextId: string;   // upstream / authority

  pattern:
    | 'customer-supplier'
    | 'conformist'
    | 'anti-corruption-layer'
    | 'open-host-service'
    | 'published-language'
    | 'shared-kernel'
    | 'partnership'
    | 'separate-ways';

  communicationMode?: string;
  description?: string;
}

export interface Repo {
  id: string;
  name: string;
  remoteUrl?: string; // clickable in UI

  contextId?: string;    // which bounded context this repo supports
  teamIds: string[];     // which teams own prod responsibility

  contributors: ContributorRef[];

  analysisSummary?: string; // optional future analysis output
}

export interface ContributorRef {
  personId: string;
}

export interface Person {
  id: string;
  displayName: string;
  emails: string[];
  teamIds?: string[];
}

export interface Team {
  id: string;
  name: string;
  jiraBoard?: string; // clickable if looks like URL
  topologyType?: 'stream-aligned' | 'platform' | 'enabling' | 'complicated-subsystem' | 'unknown';
}

export interface Group {
  id: string;
  label: string;        // e.g. "Data Platform / Ingestion"
  color?: string;       // translucent tint for hull
  contextIds: string[]; // members
  notes?: string;
}

export interface FlowStageMarker {
  name: string;         // e.g. "Data Ingestion"
  position: number;     // 0..100 along Value Stream View X axis
  description?: string; // shown in hover tooltip and inspector
  owner?: string;       // team/person responsible for this stage
  notes?: string;       // freeform notes
}

export interface User {
  id: string;
  name: string;
  description?: string;
  position: number;     // 0..100 along Strategic View X axis (horizontal only)
  isExternal?: boolean; // external users outside the organization
}

export interface UserNeed {
  id: string;
  name: string;
  description?: string;
  position: number;     // 0..100 along evolution axis (horizontal only)
  visibility?: boolean; // can be hidden without deleting
}

export interface UserNeedConnection {
  id: string;
  userId: string;
  userNeedId: string;
  notes?: string;
}

export interface NeedContextConnection {
  id: string;
  userNeedId: string;
  contextId: string;
  notes?: string;
}

export interface TemporalKeyframe {
  id: string;
  date: string; // Year or Year-Quarter: "2027" or "2027-Q2"
  label?: string;

  // Strategic View positions only
  positions: {
    [contextId: string]: {
      x: number; // Evolution axis (0-100)
      y: number; // Value chain proximity (0-100)
    };
  };

  // Which contexts exist at this point in time
  activeContextIds: string[];
}

Semantics to respect

  • strategicClassification → bubble fill color
    • core → soft gold
    • supporting → pale blue
    • generic → light gray
  • boundaryIntegrity → border style
    • strong → thick solid
    • moderate → medium solid
    • weak → dashed / porous
  • codeSize.bucket → node radius (tiny..huge → progressively larger)
  • isLegacy → show a Legacy badge (Archive icon) in the corner of the node
  • ownership → when set to 'external', show an “External” badge and dotted outer ring, and disallow repo assignment
  • positions drives layout:
    • Value Stream View uses positions.flow.x (horizontal) and positions.shared.y (vertical)
    • Distillation View uses positions.distillation.x and positions.distillation.y (independent 2D positioning)
    • Strategic View uses positions.strategic.x (horizontal) and positions.shared.y (vertical)
  • relationships arrow direction:
    • arrow points toward toContextId (upstream power)
    • shared-kernel / partnership should render as symmetric (no dominant arrowhead)

Component layout

<App />
  <ProjectListPage />          // Project list, creation, and example loading
  <EditorView />               // Active board
    <TopBar />                 // view toggle, add context, fit-to-map, undo/redo, import/export
    <MainLayout />
      <RepoSidebar />          // Unassigned repos; drag to assign to a context
      <TeamSidebar />          // Team management (tabbed sidebar with RepoSidebar)
      <CanvasArea />           // React Flow canvas:
                               //   contexts, relationships, groups, axes
      <InspectorPanel />       // Edit selected context / relationship / group /
                               //   user / user need / connection / stage / team
    <RelationshipCreateOverlay /> // When creating new relationship by drag
    <GroupCreateOverlay />        // When creating a new group from multi-select

<TopBar />

  • Toggle Value Stream View / Distillation View / Strategic View
  • Add Context
  • Fit to Map
  • Undo / Redo
  • Import Project / Export Project
  • Show active project name

<CanvasArea />

  • Renders nodes (bounded contexts)
    • fill color = strategicClassification
    • border style = boundaryIntegrity
    • badge if isLegacy
    • badge/dotted ring if ownership is 'external'
    • radius = codeSize.bucket
  • Renders edges (relationships)
    • curved bezier lines
    • arrowhead points to upstream (toContextId)
    • symmetric styling for shared-kernel / partnership
    • light obstacle avoidance so lines don't run straight through other nodes
  • Renders groups as organic blobs
    • compute padded points around each member context (adds breathing room)
    • calculate convex hull of all padded points (finds outer boundary)
    • apply Catmull-Rom curve smoothing to hull vertices (creates organic edges)
    • generate SVG path and render as translucent blob with group label
    • algorithm: d3-polygon (hull) + d3-shape (curve interpolation)
    • allow overlapping blobs (multiple groups can cover the same canvas area)
    • blob shape recomputes dynamically when contexts move or view switches
  • Renders axes
    • Value Stream View:
      • X axis = project.viewConfig.flowStages
      • Y axis label (top “user-facing / clinician-facing”, bottom “enabling / platform”)
    • Distillation View:
      • X axis = Business Differentiation (Low to High)
      • Y axis = Model Complexity (Low to High)
      • Quadrant regions: Generic (low differentiation), Supporting (high complexity or high differentiation but not both), Core (high differentiation and high complexity)
    • Strategic View:
      • X axis = Wardley bands (“Genesis / Custom-built / Product-Rental / Commodity / Utility”)
      • Y axis is unchanged
  • Drag behavior
    • In Value Stream View:
      • horizontal drag updates positions.flow.x
      • vertical drag updates positions.shared.y
    • In Distillation View:
      • horizontal drag updates positions.distillation.x
      • vertical drag updates positions.distillation.y (independent 2D positioning, NOT shared Y)
    • In Strategic View:
      • horizontal drag updates positions.strategic.x
      • vertical drag updates positions.shared.y
  • View switching
    • Uses Framer Motion to animate each node’s horizontal position when switching between Flow, Distillation, and Strategic views

Left sidebar (<RepoSidebar /> / <TeamSidebar />)

The left panel is a tabbed Teams/Repos surface, always reachable (it renders even on an empty project so teams and repos can be set up before anything is placed on the canvas) and collapses to a floating control that names both tabs. Tabs lead with Teams, then Repos. Each panel is three pinned regions inside a flex column: a filter header (name search + filter chips), a scrolling card list, and a pinned add control. Cards are uniform full-width with a status/action element top-right and a delete affordance bottom-right.

<RepoSidebar />:

  • Lists all repos, split into "Ready to assign" (no contextId) and "Assigned" sections; each card shows name, a status pill (amber Unassigned or the assigned context name), remoteUrl (clickable, truncated), and owning-team chips
  • Filter chips: All / Unassigned / Assigned; plus name search
  • Add a repo in place; delete a repo (confirmation only when assigned)
  • Click a card to select it (selectedRepoId) and open the Repo inspector
  • Drag an unassigned card onto a context in <CanvasArea /> to assign repo.contextId

<TeamSidebar />:

  • Lists teams as uniform cards with a topology badge, context count, a focus crosshair (hidden when the team owns no contexts), and delete
  • Filter chips by topology (All / Stream / Platform / Enabling / Subsystem); plus name search
  • Add a team in place; click a card to select it and open the Team inspector; drag onto a context to assign

<InspectorPanel />

When a context is selected:

  • Editable fields:
    • name
    • purpose
    • strategicClassification
    • boundaryIntegrity + boundaryNotes
    • codeSize.bucket
    • isLegacy
    • isBigBallOfMud
    • ownership
    • businessModelRole
    • evolutionStage (optional)
    • notes
  • Repos assigned to this context:
    • show repo name (and clickable remoteUrl)
    • show team chips
      • hover/click team chip → show team details:
        • name
        • topologyType
        • jiraBoard (if looks like URL, clickable)
    • show contributors (people displayName)
  • Relationships:
    • list upstream/downstream neighbors
    • label with DDD pattern (“conformist”, “customer-supplier”, etc.)

When a repo is selected (<RepoInspector />):

  • name and remote URL (editable; round-trip through Yjs)
  • assigned context (assign / unassign, reusing repo_assigned_to_context / repo_unassigned)
  • owning teams as colored chips with add and per-chip remove (edits repo.teamIds, the M:N repo↔team edge)
  • delete repository action

When a relationship is selected:

  • pattern
  • communicationMode
  • description
  • delete action

When a group is selected:

  • label, tint color, notes
  • add/remove contexts
  • delete group

<RelationshipCreateOverlay />

  • Triggered by dragging from one context to another
  • Choose pattern from allowed vocabulary
  • Optional communicationMode
  • Optional description
  • Confirm → creates Relationship

<GroupCreateOverlay />

  • Triggered by multi-select
  • Enter label, notes, color
  • Confirm → creates Group and draws translucent hull

Editor/global state

We maintain a Zustand store as a read-only projection of the Yjs document:

export type ViewMode = 'flow' | 'strategic' | 'distillation';

interface EditorState {
  activeProjectId: string | null;
  projects: Record<string, Project>;  // Populated from Yjs observers

  activeViewMode: ViewMode;

  // Selection state
  selectedContextId: string | null;
  selectedRelationshipId: string | null;
  selectedGroupId: string | null;
  selectedUserId: string | null;
  selectedUserNeedId: string | null;
  selectedUserNeedConnectionId: string | null;
  selectedNeedContextConnectionId: string | null;
  selectedStageIndex: number | null;
  selectedTeamId: string | null;
  selectedRepoId: string | null;
  selectedContextIds: string[];       // multi-select
  hoveredContextId: string | null;
  isDragging: boolean;

  // Transient UI state (not persisted, not in Yjs; cleared on project switch)
  contextDraft: ContextDraft | null;  // in-flight on-canvas context name entry
  focusContextNameId: string | null;  // one-shot: focus a context's name field in the inspector
  focus: { kind: 'team' | 'context'; id: string; depth: number } | null;  // focus lens (dim out-of-neighborhood)

  canvasView: {
    flow: { zoom: number; panX: number; panY: number };
    strategic: { zoom: number; panX: number; panY: number };
    distillation: { zoom: number; panX: number; panY: number };
  };

  // View filters
  showGroups: boolean;
  showRelationships: boolean;
  showIssueLabels: boolean;
  showTeamLabels: boolean;
  showRelationshipLabels: boolean;

  // Help preferences
  showHelpTooltips: boolean;

  // UI preferences
  groupOpacity: number;
  colorByMode: 'strategic' | 'ownership';

  // Temporal state
  temporal: {
    currentDate: string | null;
    activeKeyframeId: string | null;
  };
}

Key principle: Yjs is the single source of truth. Zustand is updated via Yjs observers. All mutations go through Yjs, which then triggers observer callbacks to update Zustand.

Undo/redo uses Y.UndoManager:

  • Scoped to user's own changes (like Figma/Miro)
  • Ctrl+Z only undoes YOUR changes, not collaborator's
  • Session-scoped (cleared on refresh)
  • Applies to all local edits: structural actions (add/move/delete context, relationships, groups, etc.) and Inspector text edits (every null-origin transaction on the project map is tracked)

Cloud Sync Architecture

Data flow:

User Action → Yjs Y.Doc → WebSocket → Cloudflare Durable Object
                ↓
           Observer callback
                ↓
           Zustand store → React components

Key components:

  • Yjs Y.Doc – CRDT document, single source of truth for project data
  • y-partyserver provider – WebSocket connection to Cloudflare
  • Cloudflare Workers + Durable Objects – Serverless backend for sync
  • Zustand store – Read-only projection updated via Yjs observers

Persistence:

  • All projects sync to cloud by default (Cloudflare Durable Objects)
  • IndexedDB used only for migration backup (legacy data from pre-cloud versions)
  • No local-only projects; cloud sync is always enabled
  • Export → download current Project as project.json
  • Import → upload project.json, creates new cloud-synced project

Offline behavior:

  • Brief disconnections handled automatically with reconnection backoff
  • Session-only offline support (changes queue locally during disconnection)
  • Refresh while offline shows blocking modal (must reconnect to continue)
  • No persistent offline mode; cloud is required for data access

Project sharing:

  • Projects are accessed via URL containing project ID
  • Anyone with the URL can view and edit (Phase 1, no auth)
  • Real-time collaboration: multiple users see changes immediately

Migration:

  • Existing IndexedDB projects auto-migrate to cloud on first load
  • Migration backup stored locally for 30 days before cleanup
  • Cloud data verified before deleting local backup

Visual rules summary

  • Fill color:
    • core → soft gold
    • supporting → pale blue
    • generic → light gray
  • Border style:
    • strong → thick solid
    • moderate → medium solid
    • weak → dashed / porous
  • Legacy:
    • Archive icon badge in corner of node
  • Ownership 'external':
    • Small “External” badge and dotted ring
    • Cannot assign repos to this node
  • Group blobs:
    • Organic, smooth shapes wrapping around member contexts
    • Generated via convex hull + Catmull-Rom curve smoothing
    • Translucent fill with adjustable opacity (slider in TopBar)
    • Minimal or subtle dashed stroke for boundary definition
    • Label text positioned in blob center or near top-left
    • Groups can overlap (multiple blobs covering same canvas area)
    • Dynamic reshaping when contexts move, view switches, or membership changes

These rules are what devs and AI must follow when rendering.


References

Bounded Context model:

  • Bounded Context Canvas by DDD Crew — the foundation for ContextFlow's bounded context attributes and metadata fields