Skip to content

Design Decisions

This document records the key architectural decisions made in the Lens project, their rationale, and the alternatives that were considered. Each decision is presented with its context, the choice made, and why.


Decision: The backend assembles complete views. The frontend is a thin rendering layer that never calls Jira APIs directly.

Context: Lens needs to display hierarchical Jira data with potentially 1,000+ issues per lens. Users expect sub-second load times. Jira Cloud imposes strict API rate limits (65,000 points/hour in Tier 1, shared globally across all installations).

Alternatives considered:

  • Client-heavy SPA — Frontend fetches Jira data directly via @forge/bridge requestJira(). Each lens open would cost hundreds of API points. At scale, the global rate limit would be exhausted within minutes.
  • Hybrid — Frontend handles some data assembly, backend handles cache. Adds complexity and still risks rate limit issues.

Why server-centric:

  1. Rate limit conservation. The issue cache means opening a lens costs zero Jira API points. Data is served from Forge SQL.
  2. Permission filtering at the data layer. The backend can filter out issues the user cannot access before the data ever reaches the frontend, preventing information leakage.
  3. Single payload. One resolver call returns the entire view. No waterfall of API calls from the frontend.
  4. Simpler frontend. The frontend receives pre-assembled rows and renders them. No complex data joining or caching logic in the browser.

Tradeoff: Backend resolver calls have a 25-second timeout. Very large lenses (approaching the 1,000-issue limit) must be assembled efficiently. Complex operations are pushed to async consumers with 15-minute timeouts.


2. Forge SQL Over Forge Storage for Structured Data

Section titled “2. Forge SQL Over Forge Storage for Structured Data”

Decision: Use Forge SQL (MySQL-compatible) for all structured data: lenses, hierarchy, views, permissions, issue cache. Use Forge KVS only for lightweight key-value data (user prefs, rate limit counters, schema version).

Context: Forge offers two storage options: Forge SQL (relational, MySQL-compatible, 1 GiB limit) and Forge Storage/KVS (key-value, 512 KB per value). Lens stores hierarchical tree data with parent-child relationships, ordering, and cross-table joins.

Alternatives considered:

  • KVS-only — Store entire hierarchy as a JSON blob in KVS. Simple for small structures, but breaks down at scale: no partial reads, no concurrent writes, 512 KB value limit.
  • KVS with indexing — Use multiple KVS keys with manual indexing. Reinvents a database poorly.

Why Forge SQL:

  1. Relational data model. Hierarchy nodes have parent-child relationships, ordering constraints, and foreign key references. SQL expresses these naturally.
  2. Efficient queries. SELECT * FROM hierarchy_nodes WHERE lens_id = ? ORDER BY depth, position is a single indexed query. The equivalent in KVS would require loading all data and sorting client-side.
  3. Concurrent writes. Multiple users editing the same lens need transactional guarantees. SQL provides row-level locking.
  4. Issue cache joins. Enriching hierarchy nodes with issue data is a batch SELECT by ID list, not a per-row KVS lookup.
  5. 1 GiB is plenty. A moderate deployment (10 lenses, 1,000 issues each) uses ~10 MB. Even at 100x scale, storage is well within limits.

Tradeoff: Forge SQL has a 150 DML/second rate limit. Bulk operations (sync agent execution creating hundreds of nodes) must batch writes carefully.


3. Event-Driven Cache Instead of Direct Jira API Reads

Section titled “3. Event-Driven Cache Instead of Direct Jira API Reads”

Decision: Maintain a local copy of Jira issue data in the issue_cache table, kept fresh by free product event triggers. Users read from cache, never from Jira API on lens open.

Context: Jira Cloud’s rate limit system (enforced March 2, 2026) charges points for every API call. A single GET /issue/{key} costs 2 points. A JQL search with 100 results costs 101 points. The Tier 1 global pool is 65,000 points/hour — shared across ALL installations of the app.

Alternatives considered:

  • Direct reads — Fetch issue data from Jira API every time a user opens a lens. At 1,000 issues per lens, opening a single lens could cost 2,000+ points. With multiple users and multiple lenses, the global budget would be exhausted in minutes.
  • Frontend caching — Cache in browser localStorage/IndexedDB. Doesn’t share across users, doesn’t survive tab closes, and still requires expensive API calls to populate.

Why event-driven cache:

  1. Product events are free. avi:jira:created/updated/deleted:issue triggers cost zero API points. The cache stays fresh automatically.
  2. One-time cost. The only API points spent are: 2 points per issue event (to fetch the full issue for caching) and the initial JQL search when a sync agent runs. Once cached, all subsequent reads are free.
  3. Sub-second loads. Reading from Forge SQL is orders of magnitude faster than calling Jira API for each issue.
  4. Scales with installations. As the app grows, cache events scale linearly with actual Jira changes, not with the number of lens opens.

Tradeoff: The cache can be briefly stale (seconds to minutes). A known platform bug (ECO-395) means avi:jira:updated:issue does NOT fire for custom workflow notification transitions, potentially causing staleness of up to 60 minutes. The three-layer auto-sync model (staleness gate, event push, count-check safety net) mitigates this.


4. No Live Sync in MVP (Refresh-on-Open Model)

Section titled “4. No Live Sync in MVP (Refresh-on-Open Model)”

Decision: MVP uses a refresh-on-open model. Forge Realtime is wired for post-sync notifications, but multi-user collaborative editing with live conflict resolution is deferred.

Context: The leading competitor has real-time multi-user editing. Forge Realtime (pub/sub) is available but in Preview status with known limitations (60 messages/channel/sec, 32 KB payload limit).

Alternatives considered:

  • Full CRDTs — Implement conflict-free replicated data types for concurrent editing. Extremely complex, and Forge’s 25-second function timeout makes server-side CRDT merging challenging.
  • Operational Transform — Like Google Docs-style collaboration. Even more complex than CRDTs, and not a natural fit for hierarchical tree data.

Why refresh-on-open:

  1. Ship faster. PPM users typically work alone on their lens. Multi-user concurrent editing is a nice-to-have, not a blocker.
  2. Event-driven auto-sync fills the gap. When someone edits an issue in Jira, the cache updates within seconds. The next time any user opens the lens, they see fresh data.
  3. Forge Realtime is Preview. Building critical features on Preview-status APIs risks breaking changes.
  4. Incremental path. The Realtime infrastructure is already in place for sync-completion notifications. Extending it to field-level updates is an incremental step, not a rewrite.

Current state: Forge Realtime is used for sync agent completion notifications and field update broadcasts. The useRealtime hook in the frontend subscribes to lens channels. Full collaborative editing is a post-MVP feature.


Decision: All state management uses React’s built-in useState, useCallback, and useMemo. No external state library.

Context: State management libraries like Redux or Zustand are common in complex React apps. Lens has significant UI complexity: grid state, Gantt state, toolbar state, inspector panel state, multiple editors.

Alternatives considered:

  • Redux — Global store with actions and reducers. Adds boilerplate, dev dependencies, and a learning curve.
  • Zustand — Lightweight alternative. Simpler than Redux but still adds a dependency.
  • React Context — Used sparingly for cross-cutting concerns (feature flags, Rovo, i18n), but not for component data state.

Why plain useState:

  1. LensView owns all data. The component hierarchy has a clear owner: LensView holds the nodes, views, and sync agents. Children receive props and callbacks. There is no “shared state” problem because the data state lives in one place.
  2. AG Grid manages its own state. The grid handles column visibility, sorting, row expansion, cell editing, and selection internally. The React layer reacts to AG Grid callbacks rather than managing this state.
  3. No cross-route state. When navigating from lens list to lens view, the view loads fresh data. There is no state that needs to persist across route transitions.
  4. Simpler debugging. With useState, the state is local and inspectable. No middleware, no action dispatch logs to trace.
  5. Forge iframe constraints. Redux DevTools and other debugging tools cannot easily reach inside the Forge cross-origin iframe anyway.

Tradeoff: As the component tree grows, prop-drilling becomes verbose. This is managed by keeping the hierarchy shallow and using callback props. If the app evolves to need cross-route state or multiple data sources writing to the same state, a state library may become warranted.


Decision: Use AG Grid as the core rendering engine for the hierarchical data grid.

Context: Lens needs a spreadsheet-like grid with: hierarchical tree data with expand/collapse, inline cell editing, column drag-and-drop reorder, column grouping, keyboard navigation, and performance at 1,000+ rows.

Alternatives considered:

  • Custom table implementation — Full control, but reimplementing tree data, virtual scrolling, column resizing, and inline editing is months of work.
  • React Table (TanStack Table) — Headless, so all rendering is custom. Provides data logic but no UI. Would still require building the entire grid UI.
  • Atlaskit DynamicTable — Atlassian’s own table component. Lacks tree data support, inline editing, and the enterprise features needed.

Why AG Grid:

  1. Tree data out of the box. AG Grid supports hierarchical data with expand/collapse, arbitrary depth, and parent-child relationships.
  2. Inline editing. Built-in cell editing with customizable editors (text, dropdown, date picker, etc.).
  3. Performance. Virtual row rendering handles 1,000+ rows without DOM bloat.
  4. Column management. Drag-to-reorder, resize, show/hide, pinning, and column groups.
  5. Extensibility. Custom cell renderers, cell editors, and header components integrate cleanly.

Tradeoffs and workarounds:

  • AG Grid’s JS-based hover system (suppressRowHoverHighlight) had to be disabled in favor of CSS :hover because it produced phantom highlights in empty viewport space.
  • cellEditorPopup: true does not work in Forge iframes (overflow clipping + wrong positioning). All editors render inline.
  • stopEditingWhenCellsLoseFocus had to be set to false because focus tracking breaks across the Forge cross-origin iframe boundary. Manual click-outside handling was implemented instead.

7. Position Gap=100 Strategy for Tree Ordering

Section titled “7. Position Gap=100 Strategy for Tree Ordering”

Decision: Use integer positions with gaps of 100 between siblings (100, 200, 300, …) to allow insertions without renumbering.

Context: Users drag-and-drop nodes to reorder them frequently. The hierarchy is stored in Forge SQL with a position column for sibling ordering.

Alternatives considered:

  • Fractional positions — Use floating-point numbers (e.g., 1.5 between 1 and 2). Precision degrades over many insertions.
  • Linked list — Each node stores a next_id pointer. Reordering requires updating two nodes. Reading order requires traversal.
  • Array-based — Store children as an ordered JSON array on the parent. Simple but requires rewriting the entire array on any move.

Why gap=100:

  1. Insert without renumber. Inserting between positions 200 and 300 uses position 250. No other rows need updating.
  2. Simple SQL. ORDER BY position ASC gives correct sibling order.
  3. Move is O(1). Update one row’s position. No cascading updates.
  4. Renumber only when gaps exhausted. After many insertions in the same spot (100+ between two siblings), a renumber pass redistributes gaps. This is rare in practice.

Decision: The codebase uses “lens” terminology exclusively. The product was rebranded from “Structure” to “Lens” to differentiate from the competitor.

Context: The original codebase and the competitor both use “structure” as the primary term. Continuing to use “structure” would cause market confusion and potential trademark issues.

Rules:

  • No structure fallbacks, aliases, or backwards-compatibility shims anywhere in new code
  • No payload?.structureId ?? payload?.lensId patterns
  • No _structure re-exports or aliases
  • If structure appears outside of migration DDL strings, it is a bug
  • Legacy URL /structure/:id redirects to /lens/:id via StructureRedirect component

Migration history: Database column names were migrated from structure_id to lens_id in schema migration v6. The structures table was renamed to lenses. All resolver names and API payloads use “lens.”


Decision: Use a “slim manifest” convention with minimized field values to fit within the ~257KB CaaS byte limit.

Context: Forge’s CaaS (Container-as-a-Service) manifest conversion has a byte limit on the converted internal format (ECO-1310). The practical ceiling is approximately 36 modules.action entries with the slim format. Lens currently has 35 actions using ~22,850 bytes.

The slim manifest convention:

  • Function keys: f1-fw, g1-g2 (2-char identifiers)
  • Module/trigger/consumer keys: 2-3 char abbreviations
  • Action descriptions: . (single dot — required by lint but not displayed)
  • Input titles: x (single char — required by lint but not displayed)
  • No action-level title: field (optional, removed)
  • required: false omitted (defaults to false)

Additionally, 6 dispatcher actions consolidate multiple logical actions into a single manifest entry:

  • lensCrud dispatches to 3 sub-actions via _action parameter
  • hierMeta dispatches to 10 sub-actions
  • viewPerm dispatches to 5 sub-actions
  • jiraUtil dispatches to 3 sub-actions
  • r2g dispatches to 19 read-only actions
  • r2r5 dispatches to 14 destructive actions

Monitoring: scripts/check-manifest-limits.js verifies the manifest stays under the byte budget. Run before adding any new action.


Decision: If the Jira API is unreachable during the BROWSE permission check, assume all issues are visible to the user.

Context: When assembling a lens view, the backend verifies that the user can BROWSE each issue in Jira. This requires a Jira API call (api.asUser() JQL check). If Jira is experiencing an outage or the rate limit is exhausted, this call may fail.

Alternatives considered:

  • Fail-closed — If the Jira API is unreachable, return an empty view. This is “safer” from a permission standpoint but makes the app completely unusable during any Jira disruption.
  • Cached permissions only — Only use KVS-cached permission results. But the cache has a 30-minute TTL, and fresh users have no cache.

Why fail-open:

  1. Usability over strictness. A Jira outage should not prevent users from accessing their own lenses. The lens owner has already granted access at the lens level.
  2. Forge SQL isolation. Cross-tenant data leakage is impossible because each installation has its own database. The fail-open path cannot leak data to other organizations.
  3. Bounded risk. The worst case is a user within the same Jira site seeing an issue they don’t have BROWSE permission on. This is a privacy concern within the same organization, not a security breach across tenants.
  4. Temporary. The fail-open state only lasts until the Jira API recovers. The next lens open with a working API will enforce permissions correctly and update the cache.

11. Adjacency List Over Nested Set for Hierarchy

Section titled “11. Adjacency List Over Nested Set for Hierarchy”

Decision: Use the adjacency list model (parent_id pointer) for the hierarchy, not nested set (left/right bounds).

Context: The hierarchy tree needs to support frequent moves (drag-and-drop reorder, indent/outdent). Both adjacency list and nested set are standard SQL tree representations.

Comparison:

OperationAdjacency ListNested Set
Move a nodeUpdate 1 row (O(1))Recalculate bounds for entire affected subtree (O(n))
Get children of nodeWHERE parent_id = ?WHERE left > parent.left AND right < parent.right
Get all descendantsRecursive CTE or app-level recursionSingle range query (fast)
Insert nodeSet parent_id and positionShift all bounds to the right (O(n))

Why adjacency list:

  • Moves are the most frequent operation in Lens. Users constantly drag-and-drop to reorder and restructure. Adjacency list makes moves O(1).
  • The depth column is denormalized to avoid recursive parent traversal during rendering.
  • Full subtree queries are not common enough to justify the nested set’s write complexity.

Decision: Use a sequential migration system with KVS-based version tracking and distributed lease for concurrency control.

Context: Forge functions can cold-start concurrently. Multiple invocations may try to run migrations simultaneously. The schema must evolve safely across versions.

How it works:

  1. ensureSchema() runs on every Forge cold-start
  2. Reads CURRENT_SCHEMA_VERSION from KVS
  3. Acquires a distributed lease (KVS-based, 120s TTL) to prevent concurrent migration runs
  4. Runs sequential migrations from current version to target version
  5. Fresh installs skip migrations and run CREATE TABLE statements directly
  6. safeDDL() wraps ALTER TABLE in try/catch for idempotency on cold-start re-runs

Why this design:

  • Forge has no built-in migration framework. The app must manage schema evolution.
  • Concurrent cold-starts are a real scenario (multiple users accessing the app simultaneously).
  • The KVS lease is simple and reliable. No external coordination service needed.
  • Sequential migrations are easy to reason about and debug.

Decision: Use react-intl (FormatJS) with ICU MessageFormat for all user-visible strings. Support 11 locales.

Context: Atlassian Marketplace apps serve a global audience. The competitor has localization support. Jira itself is available in many languages.

Supported locales: English (source of truth), Spanish, French, German, Japanese, Portuguese (Brazil), Chinese (Simplified), Korean, Italian, Russian, Dutch.

Key design choices:

  • English (en.ts) is the source of truth. All keys must exist here first.
  • Non-English bundles merge over English ({ ...en, ...locale }), so missing keys fall back to English.
  • Locale detection via navigator.language with mapping to supported locales.
  • ICU plural syntax for proper pluralization (Russian requires 3 forms).
  • Global mock in setupTests.ts returns English strings so existing test assertions work.

#DecisionKey Driver
1Server-centric architectureJira API rate limits (65K pts/hr)
2Forge SQL for structured dataRelational queries, joins, concurrent writes
3Event-driven issue cacheFree product events, zero-cost lens opens
4Refresh-on-open (no live sync MVP)Ship speed, Forge Realtime in Preview
5No Redux/ZustandLensView owns all state, AG Grid manages its own
6AG Grid EnterpriseTree data, inline editing, virtual scrolling
7Position gap=100O(1) moves without renumbering
8”Lens” terminologyProduct differentiation from competitor
9Slim manifest conventionCaaS byte limit (~257KB)
10Fail-open BROWSE checkUsability during Jira outages
11Adjacency list hierarchyO(1) drag-and-drop moves
12Sequential migrations + KVS leaseConcurrent cold-start safety
13react-intl (11 locales)Global Marketplace audience