Design a Ticket Management System

Design a Jira-like issue tracker step by step: project keys, issue numbering, configurable workflows, optimistic locking, RBAC, cache, search, notifications, and the reasoning behind every major decision.

Page content

Alice files a payment bug in project PAY. The API returns PAY-101. Bob assigns it to himself and moves it from TODO to IN_PROGRESS. A second later Priya, using a stale screen, tries the same transition.

That small race is only one of the hard parts. Two reporters may create issues in the same second and both expect a unique PAY-102. Engineering’s board uses TODO → IN_PROGRESS → CODE_REVIEW → QA → DONE. Support’s project uses OPEN → INVESTIGATING → RESOLVED → CLOSED. Search, history, and email all want a copy of the same change.

The main design question is:

How do we keep issue identity, workflow state, and history correct under concurrent edits, while search and notifications stay useful without becoming sources of truth?

We will start with one PostgreSQL database and a small API. Cache, Kafka, and OpenSearch appear only after a concrete limitation appears.

1. Clarify the problem

“Design Jira” includes sprints, boards, JQL, attachments, roadmaps, automation, and a marketplace. That is too broad for one interview.

I would ask:

  • Are we designing one company’s tracker or a multi-tenant SaaS?
  • Do projects share one global status list, or can each project define a workflow?
  • Are issue keys like PAY-101 required, or are UUIDs enough?
  • Do we need comments, labels, and links in the core design?
  • Who may create, assign, and transition issues?
  • How large is the catalog, and which other systems consume issue events?
  • Must status changes be audited immediately?

If the interviewer gives no extra constraints, I would state:

Product              Internal issue tracker for one company
Not in scope         Sprints, boards, attachments, JQL, time tracking
Identity             UUID primary keys; human keys like PAY-101
Workflow             Per-project state machine; no hardcoded statuses
Issue types          BUG, TASK, STORY, EPIC
Search               Title, description, status, assignee, labels
Auth                 Project-scoped RBAC in the backend
Notifications        In-app first; email mocked
Deployment           One region first
Stack                Go, PostgreSQL, Redis, Kafka, OpenSearch

The stack is a credible interview implementation, not a claim that twenty million issue rows need Kafka. PostgreSQL can store this catalog. Events and search exist because listing, full-text search, and notifications have different access patterns from a single-issue write.

2. Functional requirements

The system must:

  1. Create and get a project.
  2. Add and list project members.
  3. Create, get, and update an issue.
  4. Assign an issue and change its priority.
  5. Transition an issue through a configured workflow.
  6. Add comments.
  7. Add and remove labels.
  8. Link issues (BLOCKS, DUPLICATES, RELATES_TO).
  9. List issues with cursor pagination.
  10. Search issues.
  11. View immutable issue history.
  12. Configure a project’s workflow.
  13. Manage project permissions.
  14. Notify people of important events.

The first version does not include:

  • sprints, kanban boards, or velocity;
  • attachments and image pipelines;
  • JQL or saved filters;
  • time tracking and work logs;
  • automation rules; or
  • a public SaaS tenancy model.

3. Non-functional requirements

The important quality targets are:

RequirementTarget
Issue read latencyp99 below 100 ms on a cache hit
Mutation latencyp99 below 300 ms excluding search and email
DurabilityNever lose an acknowledged issue write
NumberingConcurrent creates in one project never share an issue number
ConcurrencyStale issue updates return 409, not a silent overwrite
Workflow correctnessStatus changes only through a valid transition
Search freshnessA few seconds of lag is acceptable
Notification lagSeconds are fine; the write must not wait on email
AuthorizationEnforced in the backend; the UI is not trusted
Degraded searchIssue CRUD continues if OpenSearch or Kafka is down

Consistency is not one global setting.

Issue, workflow, history in PostgreSQL    source of truth; read-your-write
Redis issue/project/workflow cache        derived; may be briefly stale
OpenSearch document                       derived; may lag by a few seconds
In-app notification                       derived; at-least-once, idempotent
Email                                     best-effort after the write commits

Edge cases to keep in mind

  • Two creates in PAY at the same instant both want the next number.
  • Alice and Bob PATCH PAY-101 with version 10.
  • Priya transitions with a from-status that is no longer current.
  • Someone PATCHes status to DONE, skipping QA.
  • An assignee is not a project member.
  • PAY-101 is linked to itself.
  • A parent issue belongs to another project.
  • Search is down while people are still filing bugs.
  • The same Kafka event is delivered twice and would email Bob twice.
  • A client retries create after a timeout.

4. Estimate the scale

Use round numbers. The goal is to find what is not the bottleneck.

Assume a large internal tracker:

Users                         20,000
Daily active users            8,000
Projects                      2,000
Issues                        20 million
Issue reads                   80/user/day
Searches                      20/user/day
Mutations                     15/user/day
Comments                      10/user/day

Read traffic:

8,000 × 80 reads = 640,000 reads/day
640,000 / 86,400 ≈ 7 average QPS
20× peak         ≈ 150 QPS

Mutations are tens per second at a noisy peak, not thousands. Twenty million issues at a few kilobytes each still fit in PostgreSQL with room to spare.

Where scale still matters:

  • A busy project’s counter row serializes creates. That is correctness, not a flaw, until one project is creating thousands of issues per second.
  • A war-room bug is a hot key: issue:{id} and PAY-1.
  • Board-style lists filter by project, status, and assignee and want stable pagination.
  • Full-text search across title and description is an awkward ILIKE.
  • Notifications fan out to watchers without blocking the transition.

Those are the reasons to add cache, a search projection, and async events later.

5. What are we storing?

We are storing projects, a reporting graph of issues, and the rules that say which status changes are legal.

Project          a container with a unique key, PAY
Issue            a work item with an internal UUID and a number
Issue key        display id PAY-101 = project.key + "-" + issue_number
Workflow         the legal graph of statuses for that project
Transition       one allowed edge in that graph
History          an immutable fact that a field or status changed
Outbox           a durable “this change happened” event

PAY-101 is what humans type. It is not the primary key. If a project is renamed, or we shard later, a UUID still identifies the row. The API can expose the pretty key and resolve it through (project_id, issue_number).

Source of truth     projects, issues, workflows, comments, links, history
Derived data        OpenSearch documents, notification rows
Cache               issue:{id}, project:{id}, workflow:{id}
Event system        outbox → Kafka
Ephemeral state     request ids, worker leases

6. APIs

Keep the API small and independent of Redis, Kafka, or OpenSearch.

Create project

POST /v1/projects
Authorization: Bearer <token>

{
  "key": "PAY",
  "name": "Payments",
  "description": "Checkout and ledger work"
}
HTTP 201 Created

{
  "id": "p_pay",
  "key": "PAY",
  "name": "Payments",
  "created_at": "2026-08-17T05:01:00Z"
}

Creating a project also creates a default workflow and a counter row at next_number = 1. Sam, the creator, becomes PROJECT_ADMIN.

Create issue

POST /v1/projects/PAY/issues
Authorization: Bearer <token>
Idempotency-Key: <uuid>

{
  "title": "Payment timeout on checkout",
  "description": "Stripe authorize hangs after 30s",
  "issue_type": "BUG",
  "priority": "HIGH",
  "assignee_id": "u_bob"
}
HTTP 201 Created

{
  "id": "i_101",
  "key": "PAY-101",
  "project_key": "PAY",
  "issue_number": 101,
  "title": "Payment timeout on checkout",
  "status": {
    "id": "s_todo",
    "name": "TODO",
    "category": "TODO"
  },
  "priority": "HIGH",
  "assignee_id": "u_bob",
  "reporter_id": "u_alice",
  "version": 1
}

The idempotency key matters. If Alice’s client times out after PAY-101 is inserted, retrying the same request should return PAY-101, not create PAY-102.

Get and update issue

GET /v1/issues/PAY-101
PATCH /v1/issues/PAY-101
Authorization: Bearer <token>

{
  "title": "Checkout timeout after Stripe authorize",
  "version": 1
}
HTTP 200 OK

{
  "key": "PAY-101",
  "title": "Checkout timeout after Stripe authorize",
  "version": 2
}

PATCH may change title, description, priority, assignee, labels-adjacent fields, due date, and parent. It must not change status. Status is a transition.

If another writer already moved the issue to version 2:

HTTP 409 Conflict

{
  "error": {
    "code": "VERSION_CONFLICT",
    "message": "Issue was updated by another request"
  }
}

Transitions

GET /v1/issues/PAY-101/transitions
HTTP 200 OK

{
  "transitions": [
    { "id": "t_start", "name": "Start progress", "to": "IN_PROGRESS" }
  ]
}
POST /v1/issues/PAY-101/transitions
Authorization: Bearer <token>

{
  "transition_id": "t_start",
  "version": 2
}

An illegal jump, or a transition whose from_status is no longer current, returns 409 INVALID_TRANSITION.

POST /v1/issues/{issueKey}/comments
GET  /v1/issues/{issueKey}/comments?cursor=...&limit=50
POST /v1/issues/{issueKey}/labels
DELETE /v1/issues/{issueKey}/labels/{label}
POST /v1/issues/{issueKey}/links
GET  /v1/issues/{issueKey}/links
GET  /v1/issues/{issueKey}/history?cursor=...

Search and lists

GET /v1/projects/PAY/issues?status=IN_PROGRESS&assignee=u_bob&limit=50&cursor=...
GET /v1/issues/search?q=payment&project=PAY&status=IN_PROGRESS
HTTP 200 OK

{
  "items": [ { "key": "PAY-101", "title": "Checkout timeout after Stripe authorize" } ],
  "next_cursor": "eyJuIjoxMDEsInAiOiJwX3BheSJ9",
  "limit": 50
}

Errors

Clients always see a stable envelope. Internal SQL stays in logs.

{
  "error": {
    "code": "ISSUE_NOT_FOUND",
    "message": "Issue not found"
  }
}
400  VALIDATION_ERROR, INVALID_PARENT, SELF_LINK
401  UNAUTHENTICATED
403  FORBIDDEN
404  ISSUE_NOT_FOUND, PROJECT_NOT_FOUND, TRANSITION_NOT_FOUND
409  VERSION_CONFLICT, INVALID_TRANSITION, PROJECT_KEY_EXISTS
500  INTERNAL_ERROR

7. Basic data model

Project and counter

projects
--------
id            UUID PK
key           unique  (PAY)
name
description
created_by
created_at
updated_at

project_counters
----------------
project_id    PK, FK → projects.id
next_number   integer

One counter row per project is enough. It exists so creates can take a short lock on a single well-known row.

Issue

issues
------
id               UUID PK
project_id       FK → projects.id
issue_number     integer
title
description
issue_type       BUG | TASK | STORY | EPIC
status_id        FK → workflow_statuses.id
priority
reporter_id
assignee_id      nullable
parent_issue_id  nullable FK → issues.id
due_date
created_at
updated_at
version          integer, starts at 1

UNIQUE (project_id, issue_number)

Important indexes:

(project_id, issue_number)   resolve PAY-101
project_id                   list by project
assignee_id, reporter_id
status_id, priority
(project_id, status_id, id)  board-style list + cursor
created_at, updated_at

Rules:

assignee, if set, is a project member
parent, if set, is another issue in the same project
parent_issue_id != id

Deep epic trees can theoretically cycle. For the interview, reject self-parent and optionally walk a bounded parent chain. Do not start with a closure table.

Workflow

workflows
---------
id
project_id
name

workflow_statuses
-----------------
id
workflow_id
name
category          TODO | IN_PROGRESS | DONE

workflow_transitions
--------------------
id
workflow_id
from_status_id
to_status_id
name

category is a coarse bucket for reporting (“how many items are done?”). The legal next step is the transition row, not the category.

project_members     (project_id, user_id, role)  unique (project_id, user_id)
comments            id, issue_id, author_id, body, created_at, updated_at
labels              id, name unique
issue_labels        (issue_id, label_id)
issue_links         id, source_issue_id, target_issue_id, link_type, created_at
issue_history       id, issue_id, actor_id, event_type, old_value, new_value, created_at
outbox_events       id, aggregate_type, aggregate_id, event_type, payload,
                    created_at, processed_at, retry_count
processed_events    (consumer, event_id) unique

History is insert-only. processed_events is how consumers survive duplicate Kafka delivery.

Store links in one direction. PAY-101 BLOCKS PAY-200 is one row. The API can also show PAY-200 is blocked by PAY-101 by querying both source and target.

8. Start with one service and PostgreSQL

Do not begin with Issue Service, Search Service, and Notification Service as three deployables. One Go process and PostgreSQL can implement every functional requirement.

Client
HTTP Handler
  │  authn, request id, JSON
Service
  │  RBAC, workflow rules, transactions
Repository
PostgreSQL

Handlers parse HTTP and map domain errors to status codes. Services own business rules. Repositories own SQL. Kafka, Redis, and OpenSearch are adapters behind small interfaces where they help tests. They are not a forest of one-line interfaces.

At this stage the API already creates projects and issues. A default workflow is enough: TODO → IN_PROGRESS → DONE. We will generalize it as soon as a second project needs different names.

9. Human keys are not primary keys

People want PAY-101. Databases want a stable surrogate.

If PAY-101 is the primary key:

renaming the project key  rewrites every row, comment, and link
merging projects          collides numbers
sharding later            the pretty key is a terrible partition key

Keep both:

id             UUID, internal, stable
issue_number   101, unique per project
key            computed: PAY + "-" + 101

Resolve GET /issues/PAY-101 with a join on projects.key and issues.issue_number. Cache the mapping if it is hot. Never let the display key leak into foreign keys.

10. Issue numbers: lock the counter, not the whole table

MAX(issue_number) + 1 under read committed can duplicate:

Alice reads max=100
Bob   reads max=100
Alice inserts 101
Bob   inserts 101     → unique violation or a silent collision

A per-project counter with a short row lock is the simple production-credible fix:

BEGIN
  SELECT next_number
  FROM project_counters
  WHERE project_id = $pay
  FOR UPDATE

  number = next_number
  next_number = next_number + 1

  INSERT issue (... issue_number = number, version = 1)
  INSERT issue_history ISSUE_CREATED
  INSERT outbox IssueCreated
COMMIT

Alice and Bob now queue on one row in PAY. Creates in BILL do not wait. A unique constraint on (project_id, issue_number) is the backstop if application code forgets the lock.

Gaps are allowed. If the transaction rolls back after incrementing, PAY-102 may never exist. Jira-like products already have gaps. Duplicates are not allowed.

A PostgreSQL SEQUENCE per project is an alternative. It also permits gaps and avoids rolling your own counter, but creating a sequence on every new project is more operational work. SELECT FOR UPDATE on one counter row is easier to draw on a whiteboard.

This lock is pessimistic and tiny: it lives only for the create transaction. It is not held while calling Kafka, Redis, or email.

11. Two people edit the same issue

Numbering serializes creates in a project. Updates of PAY-101 should not serialize the whole project.

Alice and Bob both open PAY-101 at version 10.

Alice PATCH title,    version=10  → success, version=11
Bob   PATCH assignee, version=10  → 0 rows updated → 409
UPDATE issues
SET title = $1,
    version = version + 1,
    updated_at = now()
WHERE id = $2
  AND version = $3

If no row matches, either the issue is gone or someone else wrote. Distinguish those with a follow-up read.

Optimistic locking fits because two people colliding on the same bug is uncommon compared with two people creating unrelated bugs. The version predicate belongs in SQL so two app nodes cannot both think they won.

A unique constraint on project key covers the other common race: two POST /projects with PAY.

12. Why status is not a PATCH field

If status is just another column, this request “works”:

PATCH /issues/PAY-101
{ "status": "DONE", "version": 11 }

It skips CODE_REVIEW and QA. It also lets a client invent SHIPPED, a name that does not exist on this project.

Status is not a string field. It is a move on a graph. The product operation is “apply this transition,” which is why the API is:

POST /issues/PAY-101/transitions
{ "transition_id": "t_start", "version": 11 }

PATCH still updates title and assignee. Mixing “edit the description” with “close the ticket” in one endpoint hides the business rule.

13. Configurable workflows

Hardcoding TODO, IN_PROGRESS, DONE in service if statements fails as soon as Support wants OPEN and INVESTIGATING.

Store the graph:

Engineering / PAY

TODO
  --Start progress--> IN_PROGRESS
IN_PROGRESS
  --Ready for review--> CODE_REVIEW
CODE_REVIEW
  --Send to QA--> QA
QA
  --Pass--> DONE
  --Fail--> IN_PROGRESS
Support / HELP

OPEN
  --Investigate--> INVESTIGATING
INVESTIGATING
  --Resolve--> RESOLVED
RESOLVED
  --Close--> CLOSED
  --Reopen--> OPEN

The workflow service does not know those names. It knows:

transition.workflow_id == issue.workflow_id
issue.status_id == transition.from_status_id
actor may TRANSITION_ISSUE

Categories (TODO, IN_PROGRESS, DONE) let dashboards group foreign names. They are not edges. DONE in Engineering and CLOSED in Support can share category DONE without sharing a status id.

Clone a default workflow when a project is created so the first issue has a start status. PROJECT_ADMIN can add statuses and transitions later. Do not let an in-flight issue sit on a deleted status; reject deleting a status that is still referenced, or require a migration transition.

A JSON column of allowed next statuses is tempting and worse. It is harder to index, harder to validate, and harder to query “what can leave QA?”. Explicit transition rows are the model you can explain.

14. The transition transaction

This is the core write path. Keep it short and closed over only PostgreSQL.

Transition(actor, issueKey, transitionID, version):
  authorize TRANSITION_ISSUE
  return inTransaction:
    issue = load issue by key
    if issue.version != version: conflict

    t = load transition
    if t missing or t.workflow != issue.workflow: not found / invalid
    if issue.status_id != t.from_status_id: INVALID_TRANSITION

    UPDATE issues
      SET status_id = t.to_status_id,
          version = version + 1
      WHERE id = issue.id AND version = version
    if 0 rows: conflict

    INSERT issue_history STATUS_CHANGED old, new
    INSERT outbox IssueStatusChanged
  after commit:
    invalidate issue:{id}
    return issue

History is in this transaction because GET /issues/PAY-101/history should show the move immediately. Notifications are not. Email is slow, can fail, and must not hold the row lock or the HTTP request.

Invalid transitions return 409, not 400. The request was well-formed; the issue is not in the expected state. That matches optimistic locking: the world moved.

Do not call Redis, Kafka, or SMTP inside BEGIN … COMMIT.

Comments are append-heavy. Insert a row, insert COMMENT_ADDED history, insert an outbox event, commit. List with a cursor on (created_at, id), not OFFSET.

Labels are a many-to-many. Adding a label is a small transaction on issue_labels plus history. A unique (issue_id, label_id) makes retries safe.

Links are one stored direction:

PAY-101  BLOCKS      PAY-200
PAY-101  RELATES_TO  PAY-300
PAY-400  DUPLICATES  PAY-101

Reject source == target. Cross-project links are a product choice; I would allow them for RELATES_TO and require the same project for BLOCKS unless the interviewer wants otherwise. Either is defensible if you state it.

Derive the reverse on read:

PAY-200 is blocked by PAY-101

Do not store both directions. Dual rows go stale independently.

16. History belongs next to the issue

issue_history is not an analytics stream. It is the product’s activity tab.

Write it in the same PostgreSQL transaction as the mutation:

ISSUE_CREATED
TITLE_CHANGED
ASSIGNEE_CHANGED
STATUS_CHANGED
PRIORITY_CHANGED
LABEL_ADDED
LABEL_REMOVED
COMMENT_ADDED
LINK_ADDED

The table is insert-only. There is no UPDATE API. performed_by / actor_id comes from the authenticated actor, never from the JSON body.

Kafka may still carry the same event for search and notifications. That is a projection of history, not a second source of truth. If the audit consumer is behind, the activity tab is still correct.

This is the opposite of a design that inserts only an outbox row and lets an Audit Service create history. That design makes GET /history eventually consistent. For a ticket tracker, people click History immediately after they transition.

17. Cursor pagination

Offset pagination breaks on a live project:

page 2 = OFFSET 50

A new PAY-50 inserted on page 1 shifts everyone, and large offsets still walk discarded rows.

List issues with a stable order and a unique cursor, typically (id) or (updated_at, id) plus filters:

WHERE project_id = $pay
  AND status_id = $in_progress
  AND id > $cursor
ORDER BY id
LIMIT 50

Return an opaque next_cursor. If filters change, the cursor is invalid.

Do not load a project’s entire backlog into memory to sort it in the application.

18. RBAC is per project

A company-wide ADMIN role is the wrong default. Sam may administer PAY and only view HELP.

PROJECT_ADMIN   everything in that project
DEVELOPER       view, create, edit, assign, transition, comment
REPORTER        create, view, comment
VIEWER          read-only

Map roles to permissions in code, not in the browser:

CREATE_ISSUE
EDIT_ISSUE
ASSIGN_ISSUE
TRANSITION_ISSUE
ADD_COMMENT
VIEW_PROJECT
MANAGE_WORKFLOW
MANAGE_PROJECT_MEMBERS
Alice VIEWER     GET PAY-101                 allowed
Alice VIEWER     POST transition             403
Bob   DEVELOPER  POST transition             allowed
Bob   DEVELOPER  POST /workflows             403
Sam   ADMIN      POST /members               allowed

Authenticate in middleware. Authorize in the service after the issue’s project_id is known. A hidden “Transition” button is not a control.

Keep the matrix in one function. A general policy language is unnecessary for four roles.

19. Cache issue, project, and workflow

GET /issues/PAY-101 is hot. Workflow graphs change rarely and are read on every transition validation.

Cache-aside:

GET issue:{id}
        ├── hit  → return
        └── miss → PostgreSQL → SET issue:{id} → return

Suggested keys:

issue:{issueID}
project:{projectID}
workflow:{workflowID}
issue_key:PAY-101 → issueID     optional mapping

On every successful mutation, invalidate. Do not update-in-place. A racing writer can otherwise leave a newer row behind an older cache fill.

Redis is not the source of truth. If Redis is down:

Read     go to PostgreSQL
Write    still commit; log the failed invalidation

A missed invalidation means a stale issue until TTL expiry. Use a modest TTL, for example 1–5 minutes for issues and longer for workflows, as a safety net.

Do not cache search result pages as the primary list implementation. Filters explode the key space. Cache the issue object; let list queries hit Postgres or OpenSearch.

20. Why search and email cannot share the write transaction

Suppose Bob transitions PAY-101 and we also update OpenSearch and send email in the request:

BEGIN PostgreSQL update
update OpenSearch
send email
COMMIT

OpenSearch and SMTP are not in that transaction.

Postgres succeeds, email fails     issue moved, Bob is not told
Email succeeds, Postgres rolls back Bob got mail for a move that did not happen

That is the dual-write problem. The issue write must not depend on search or mail being up.

21. Transactional outbox

Write the event in the same database transaction as the issue:

BEGIN
  UPDATE issues ... WHERE id = $id AND version = $v
  INSERT issue_history ...
  INSERT outbox_events (
    aggregate_type, aggregate_id, event_type, payload
  )
COMMIT

If the version predicate matches zero rows, insert nothing and return 409.

A relay worker publishes afterward:

SELECT unpublished outbox rows
publish to Kafka topic issue.events
mark processed_at
on failure: increment retry_count, leave unpublished

If Kafka is down, rows wait. They are not lost. Publishing is at-least-once, so consumers must be idempotent.

Useful event types:

IssueCreated
IssueUpdated
IssueAssigned
IssueStatusChanged
IssuePriorityChanged
CommentAdded
IssueLabelChanged
IssueLinked

Poll in created_at order, or LISTEN/NOTIFY plus a safety poll. Skip CDC unless the interviewer pushes; an outbox table is easier to inspect and to explain.

Partition Kafka by issue_id so PAY-101 stays ordered. Global order across issues is unnecessary.

22. Search and notifications are consumers

PostgreSQL
Outbox
Kafka issue.events
    ├── Search indexer      → OpenSearch
    └── Notification service → in-app rows, mocked email

OpenSearch holds a denormalized document:

issue_id, issue_key, project_id, project_key,
title, description, issue_type, status, priority,
assignee, reporter, labels, created_at, updated_at
GET /issues/search?q=payment&project=PAY&status=IN_PROGRESS

PostgreSQL remains authoritative. If OpenSearch is down, search returns 503 or a narrow SQL fallback (exact key, exact assignee). POST /issues still succeeds.

Rebuild by replaying from PostgreSQL or retained Kafka and upserting by issue_id. Duplicate events must not create duplicate documents.

Notifications:

issue assigned           notify the new assignee
status changed           notify assignee and reporter
comment added            notify assignee, reporter, and watchers
priority changed         notify assignee

The first version stores in-app rows and logs “would send email.” The HTTP transition does not wait. If the mail provider is down, the notification worker retries. The issue is already IN_PROGRESS.

Do not notify from inside the issue service with a direct HTTP call to SMTP. That recreates dual-write and holds the user request on a provider.

23. Idempotent consumers

Kafka will deliver IssueStatusChanged twice.

BEGIN
  INSERT INTO processed_events (consumer, event_id)
  ON CONFLICT DO NOTHING
  if not inserted: skip
  else:
    apply side effect
COMMIT

Unique (consumer, event_id) is the whole trick. Search upserts by issue_id as a second line of defense. Notifications must use the processed-event row; an upsert on “Bob already has a row for PAY-101” would hide a later, legitimate second assignment.

At-least-once plus idempotency is the default. Exactly-once across Postgres, Kafka, and OpenSearch is not what this interview is asking for.

24. Consistency model

Say this out loud.

Strong, same transaction
  issue row, version, numbering lock, workflow check,
  history insert, outbox insert

Read-your-write for the actor
  after a successful transition, GET issue and GET history
  read PostgreSQL (or a request-local copy)

Eventual
  Redis, OpenSearch, in-app notifications, email

Unacceptable
  duplicate issue numbers, skipped workflow, lost updates,
  history that disagrees with the issue row

A reader can see an old cached PAY-101 for a short window. The next transition still uses PostgreSQL id + version. That is allowed. Serving a transition from Redis is not.

25. Failure scenarios

FailureBehavior
PostgreSQL downFail writes and authoritative reads; do not invent issues from cache
Redis downReads go to PostgreSQL; mutations still commit
Kafka downOutbox accumulates; CRUD continues; search and mail lag
OpenSearch downCRUD continues; search degrades or returns 503
Notification provider downIn-app row can still commit; email retries
Outbox worker crashUnpublished rows remain; another worker resumes
Duplicate Kafka eventprocessed_events unique key skips the second apply
Invalid transition under a race409; client reloads allowed transitions

A cached issue during a database outage is a product decision. I would not serve it for transitions. A stale “still TODO” is better than a transition that cannot be recorded.

26. Observability, security, and scale

Keep observability light:

Logs          request_id, actor_id, issue_key, version, error code
Latency       handler duration
Cache         hit / miss / error
Outbox        unpublished age, publish failures
Kafka         consumer lag, handler failures
Search        indexer lag

The metric that matters is commit-to-index lag and commit-to-notify lag, not only HTTP QPS.

Security:

  • Authorize every route with project membership.
  • Rate-limit search and history export.
  • Take actor_id from the token.
  • Do not return SQLSTATE to clients.

Scaling:

You will not shard issues by UUID in this interview. Indexes, a read replica, and OpenSearch cover 20 million rows.

If one tenant is a SaaS with billions of issues:

Partition by project_id, not by issue UUID
Keep an issue's comments and history on the same project partition
Do not put two tenants on one hot counter

The per-project counter is already a natural shard key for creates. Multi-region active-active writes on the same issue fight the version column. Home-region for a project is enough.

Split search from OLTP before you split the write database. Split notifications before you split issues from projects.

27. Where logic lives

This is the LLD the interview wants: transactional boundaries, not a framework dump.

cmd/server
internal/project          handler, service, repository
internal/issue            handler, service, repository
internal/workflow         graph load, transition validation
internal/comment
internal/auth             membership and permission checks
internal/cache            Redis adapter; errors are not panics
internal/outbox           writer used inside transactions; relay worker
internal/search           indexer consumer; query adapter
internal/notification     consumer; in-app store; mocked mail
migrations/

One process, several packages. That modular monolith can become separate services later because Kafka already carries committed events. Do not pay that operational cost on day one.

Handlers never open transactions. Repositories never decide whether QA → DONE is legal. Tests fake the repository for workflow rules and use a real database for SELECT FOR UPDATE and the version WHERE clause.

28. Final architecture

Only now do the extra boxes earn their place:

 Client
API Gateway  (authn, request id, rate limit)
Ticket Service  (projects, issues, workflows, comments)
   ├── PostgreSQL
   │     projects, counters, issues, workflows,
   │     history, outbox, members
   ├── Redis     issue:{id} / workflow:{id}
   └── invalidate on write

PostgreSQL outbox
Outbox worker
Kafka issue.events
   ├── Search indexer → OpenSearch
   └── Notification  → in-app + mocked email

Create issue:

BEGIN
  lock project_counters
  insert issue with next number
  insert history + outbox
COMMIT
invalidate cache
return PAY-101

Transition:

BEGIN
  check version, membership, transition edge
  update status and version
  insert history + outbox
COMMIT
invalidate cache
return new status

Search and notify happen after commit, at least once, idempotently.

29. Interview-ready summary

Key decisions to remember

  1. Scope the interview to projects, issues, workflow, comments, search, and notifications — not boards or JQL.
  2. PostgreSQL is the source of truth; twenty million issues fit.
  3. UUIDs are primary keys; PAY-101 is a computed display key.
  4. Allocate issue numbers with a per-project counter and SELECT FOR UPDATE.
  5. Optimistic-lock issue updates on id + version; return 409 on conflict.
  6. Status changes only through configured transitions, never through PATCH.
  7. Write issue_history and the outbox row in the same transaction as the mutation.
  8. Cache-aside Redis; invalidate after commit; ignore Redis on the write path.
  9. Kafka consumers for search and notifications are idempotent via processed_events.
  10. RBAC is per project and enforced in the service layer.

Senior interview discussion points

These are the questions that usually follow a strong whiteboard.

1. Why isn’t PAY-101 the primary key? Humans need it; the database needs a stable UUID. Project rename, merge, and partitioning all get worse if the pretty key is the identity.

2. How do you stop two creates from both becoming PAY-102? Lock the project’s counter row in the create transaction, insert, increment, commit. Unique (project_id, issue_number) is the backstop. Gaps are fine.

3. Why pessimistic numbering and optimistic updates? Creates contend on one counter per project; the lock is short. Field edits contend per issue and are rare, so version plus 409 is enough.

4. Why not MAX(issue_number)+1? Two transactions can read the same max. You either duplicate or rely on a unique violation retry loop that still needs a deterministic number.

5. Why not hardcode TODO / IN_PROGRESS / DONE? The next project will want different names. The service should interpret a graph of transitions, not a switch on English words.

6. Why can’t PATCH change status? Skipping QA is not a validation bug in a string field; it is an illegal move. Make the move an explicit application of a transition id.

7. What happens if two transitions race? Both load version 10. One commits TODO → IN_PROGRESS and version 11. The other updates zero rows or sees from_status mismatch and returns 409.

8. Why is history in the write transaction but email is not? The activity tab is part of the issue’s truth. Email is slow, failure-prone, and allowed to lag. Holding a DB transaction open for SMTP is the design to reject.

9. Why an outbox instead of kafka.Publish after COMMIT? The process can die between commit and publish. The outbox row is committed with the issue, so a worker can finish the job.

10. Why not update OpenSearch in the request? It is a dual write. Search can be down. CRUD must not be.

11. How are consumers idempotent? Unique (consumer, event_id) in processed_events. Search also upserts by issue id. Notifications must not use “already assigned” as the only key.

12. What if Kafka is down for an hour? Outbox grows. Issues still transition. Search and notifications catch up. Monitor unpublished age.

13. How do you authorize a developer in PAY but not HELP? Membership is (project_id, user_id, role). Load the issue, then check the role in that project. Global roles are the wrong default.

14. How do you paginate a busy project? Cursor on a unique ordered column, with filters in the WHERE clause. Avoid OFFSET 10000.

15. How are issue links stored? One row, one direction. Reverse text is a read-side presentation. Reject self-links.

16. What if Redis has a stale issue? Writes still go to PostgreSQL with a version check. At worst a reader sees old title text until invalidation or TTL.

17. How would you shard later? By project_id, keeping comments, history, and the counter on the same project. Do not shard by issue UUID if you still list by project.

18. When does the modular monolith split? When search, notifications, or a separate team needs an independent deploy and SLO. The outbox already defines the event contract.

19. How do you rebuild the search index? Scan PostgreSQL or replay Kafka and upsert by issue_id. Because documents are keyed, rebuilds are idempotent.

20. What is the invariant you will not violate? No duplicate (project_id, issue_number), no status change outside a stored transition, no lost update on a matching version, and no history row that disagrees with the committed issue.

Senior-level points that differentiate the answer

  • State that Kafka and OpenSearch are for access patterns, not because SQL ran out of space.
  • Distinguish the counter lock (create) from optimistic version (update).
  • Put workflow checks, version, history, and outbox in one transaction.
  • Keep notifications and search out of that transaction.
  • Fail closed on authorization and illegal transitions; fail open on cache and search.
  • Monitor commit-to-index lag, not only HTTP latency.
  • Keep one service until a real ownership boundary appears.

A 1–2 minute verbal answer

I would scope this to a Jira-like tracker without boards or JQL: projects, members, issues, configurable workflows, comments, labels, links, search, history, and notifications. Twenty million issues still fit in PostgreSQL. The hard parts are numbering, concurrent edits, and workflow.

Issues have a UUID primary key and a per-project number. PAY-101 is computed. Creates lock project_counters with SELECT FOR UPDATE so numbers never collide. Updates use optimistic locking on id and version and return 409 on conflict. Status is not a PATCH field. Each project has a graph of transitions; applying one checks membership, from_status, and version in a single transaction that also writes history and an outbox row.

Redis caches issues and workflows with cache-aside and invalidate-on-write. A worker publishes the outbox to Kafka. A search indexer upserts OpenSearch and a notification consumer writes in-app alerts, both using processed_events for idempotency. If Redis, Kafka, or OpenSearch is down, issue writes still commit.

For the broader interview framework around this problem, see the System Design Interview Complete Guide.