Building a Production-Grade Jira/Linear — Workflows, Boards, Integrations & Scale
1. Introduction & Why Issue Tracking is Hard
Every software team, from a two-person startup to a thousand-engineer enterprise, needs a system to track work. Bugs need to be reported, prioritized, assigned, and verified. Features need to be designed, estimated, developed, tested, and shipped. Operational incidents need to be triaged, mitigated, and post-mortemed. The humble issue tracker sits at the center of all of this activity, acting as the single source of truth for "what needs to be done, who is doing it, and what is the current state."
Building an issue tracker that works well is deceptively complex. At first glance, it seems like a simple CRUD application: create an issue, update its status, add a comment. But the reality is far more nuanced. Consider the following challenges:
- Customizable workflows: Different teams use different processes. A backend team might have states like "To Do → In Progress → In Review → QA → Done." A design team might have "Backlog → Designing → Reviewing → Approved → In Dev." The system must support arbitrary state machines per project.
- Real-time collaboration: Multiple team members view and modify the same issues simultaneously. When Alice moves an issue to "In Review," Bob (looking at the same board) should see the card move instantly without refreshing.
- Complex relationships: Issues can block other issues, be duplicated by others, be children of epics, and be linked to commits, pull requests, and deployments. The relationship graph can be deeply nested.
- Powerful search and filtering: A product manager needs to find "all high-priority bugs assigned to the payments team, created in the last 30 days, that are blocked by another issue." This requires a flexible query engine.
- Integration ecosystem: The issue tracker must connect with Git providers, CI/CD pipelines, Slack, email, and dozens of other tools via webhooks and APIs.
- Scale and performance: A large organization might have 500,000 issues across 2,000 projects. Search, board rendering, and reporting must remain fast despite this volume.
Real-World Case Studies
| Platform | Scale | Key Innovation |
|---|---|---|
| Jira (Atlassian) | 65,000+ customers, billions of issues | Custom workflows per project, marketplace of 3,000+ plugins |
| Linear | 10,000+ teams, sub-100ms interactions | Keyboard-first UX, opinionated workflows,极致 performance |
| GitHub Issues | 100M+ developers | Tight Git integration, milestones, project boards |
| GitLab Issues | 30M+ users | Integrated CI/CD, time tracking, epic hierarchy |
| Shortcut (formerly Clubhouse) | Thousands of teams | Story-based workflow, iteration planning, Slack-native |
Linear's approach deserves special attention. By enforcing a streamlined, opinionated workflow (Backlog → Todo → In Progress → Done) and building their entire frontend with React and GraphQL subscriptions, they achieved sub-100ms interaction latency for board operations. The lesson is that sometimes constraining flexibility leads to a dramatically better user experience. Our design takes inspiration from both Jira's flexibility and Linear's performance philosophy.
2. Functional & Non-Functional Requirements
Functional Requirements
- Issue CRUD: Create, read, update, and delete issues with rich text descriptions, attachments, and metadata.
- Issue types: Support Bugs, Stories, Tasks, Epics, and custom issue types per project.
- Custom workflows: Each project can define its own state machine with custom states, transitions, and transition rules (e.g., require approval to move to "Done").
- Board views: Kanban boards (continuous flow) and Scrum boards (sprint-based) with drag-and-drop card movement.
- Sprint management: Create sprints, plan capacity, move issues between sprints and backlog.
- Backlog: A prioritized list of uncheduled issues with drag-to-reorder.
- Search and filtering: JQL-like query language for complex searches, saved filters, and shared views.
- Comments and activity: Threaded comments, @mentions, and a full activity feed per issue.
- File attachments: Upload screenshots, logs, and documents to issues with drag-and-drop support.
- Labels and components: Categorize issues with free-form labels and structured components.
- Reporting: Burndown charts, velocity charts, cycle time metrics, and custom dashboards.
- Roadmapping: Timeline view mapping epics to dates for long-term planning.
- Git integration: Branch creation from issues, commit linking, PR association.
- CI/CD integration: Link builds and deployments to issues, show build status on issue pages.
- Notifications: In-app real-time notifications, email digests, Slack integration.
- SLA and time tracking: Track time spent on issues, define SLA policies with breach alerts.
- Automation rules: If-this-then-that rules (e.g., "when status changes to Done, move issue to next sprint").
- Audit log: Complete history of all changes for compliance and debugging.
- Bulk operations: Select multiple issues and move, assign, or transition them in batch.
- Import/Export: Import from Jira, CSV, or other tools; export to CSV and JSON.
- API and webhooks: RESTful API, GraphQL API, and outbound webhooks for integration.
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Latency (board load) | < 200ms for boards with up to 500 issues |
| Latency (search) | < 500ms for filtered queries across 1M issues |
| Availability | 99.9% uptime (8.76 hours downtime/year) |
| Real-time sync | < 200ms propagation for board updates via WebSockets |
| Throughput | 10,000 issue updates/second across all tenants |
| Storage | Support 100M issues with 10TB attachments |
| Multi-tenancy | Complete data isolation between organizations |
| Security | SOC 2 Type II, GDPR compliant, RBAC with project-level permissions |
3. Issue Modeling: Bugs, Stories, Tasks & Epics
The core entity in the system is the Issue. But not all issues are the same. A bug that needs immediate fixing is fundamentally different from an epic that represents a multi-sprint initiative. The type system must be flexible enough to handle standard types while allowing project-level customization.
Issue Type Hierarchy
text
Epic
├── Story (user-facing feature)
│ ├── Task (technical work)
│ ├── Sub-task (granular breakdown)
│ └── Bug (defect found during development)
├── Story
│ └── ...
└── Bug (standalone bug, not part of an epic)
Sub-tasks are the only type that cannot have children.
Epics cannot be assigned to sprints directly (only their children).
Issue Entity Schema
C#
public class Issue
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Identifier { get; set; } // e.g., "PROJ-123"
public Guid ProjectId { get; set; }
public IssueType Type { get; set; } // Bug, Story, Task, Epic, SubTask
public string Title { get; set; }
public string Description { get; set; } // Markdown / Rich Text
public IssueStatus Status { get; set; }
public IssuePriority Priority { get; set; }
public IssueSeverity? Severity { get; set; } // Primarily for bugs
public Guid? AssigneeId { get; set; }
public Guid? ReporterId { get; set; }
public Guid? ParentId { get; set; } // For sub-tasks and child issues under epics
public Guid? EpicId { get; set; }
public Guid? SprintId { get; set; }
public Guid? MilestoneId { get; set; }
public List<string> Labels { get; set; } = new();
public List<Guid> ComponentIds { get; set; } = new();
public int StoryPoints { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public DateTime? DueDate { get; set; }
public int? Order { get; set; } // For backlog ordering
public Dictionary<string, object> CustomFields { get; set; } = new();
public List<IssueLink> LinkedIssues { get; set; } = new();
public List<Attachment> Attachments { get; set; } = new();
}
public enum IssueType
{
Bug,
Story,
Task,
Epic,
SubTask
}
public enum IssuePriority
{
Urgent = 0,
High = 1,
Medium = 2,
Low = 3,
NoPriority = 4
}
public enum IssueSeverity
{
Critical,
Major,
Minor,
Trivial
}
public class IssueLink
{
public Guid Id { get; set; }
public Guid SourceIssueId { get; set; }
public Guid TargetIssueId { get; set; }
public LinkType Type { get; set; } // Blocks, BlockedBy, Duplicates, Related
}
public enum LinkType
{
Blocks,
BlockedBy,
Duplicates,
IsDuplicatedBy,
RelatesTo
}
SprintId directly on the issue rather than using a join table. This simplifies the most common query ("show me all issues in sprint X") and allows a single indexed lookup. The trade-off is that changing an issue's sprint requires updating the issue row, but this is a lightweight operation compared to the read frequency.
Project Configuration
Each project defines its own issue types, workflows, and custom fields. A project configuration encapsulates:
C#
public class ProjectConfiguration
{
public Guid ProjectId { get; set; }
public List<IssueTypeConfig> IssueTypes { get; set; }
public Dictionary<IssueType, WorkflowDefinition> Workflows { get; set; }
public List<CustomFieldDefinition> CustomFields { get; set; }
public List<Component> Components { get; set; }
public IssueFieldConfiguration FieldConfig { get; set; } // Which fields are required/optional per type
}
public class IssueTypeConfig
{
public IssueType Type { get; set; }
public string Name { get; set; }
public string Icon { get; set; } // URL or icon key
public bool IsEnabled { get; set; }
public bool AllowsSubTasks { get; set; }
}
This configuration-driven approach means that adding a new issue type or modifying a workflow does not require code changes — it is a metadata operation handled through the admin API. The system reads the configuration at runtime and applies the appropriate rules.
4. Custom Workflows & State Machines
The workflow engine is the heart of the issue tracker's flexibility. Each issue type within a project can have its own workflow — a directed graph of states and transitions. This is what makes Jira powerful enough for enterprises and what makes Linear simple enough for startups (Linear just uses a very constrained default workflow).
Workflow Definition
C#
public class WorkflowDefinition
{
public Guid Id { get; set; }
public string Name { get; set; }
public List<WorkflowState> States { get; set; }
public List<WorkflowTransition> Transitions { get; set; }
public Guid StartStateId { get; set; }
public List<Guid> EndStateIds { get; set; } // States considered "done"
}
public class WorkflowState
{
public Guid Id { get; set; }
public string Name { get; set; } // "To Do", "In Progress", "In Review", "Done"
public StateCategory Category { get; set; } // Todo, InProgress, Done
public string Color { get; set; }
public bool RequiresAssignee { get; set; }
public bool IsTerminal { get; set; } // Cannot transition out
}
public class WorkflowTransition
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid FromStateId { get; set; }
public Guid ToStateId { get; set; }
public List<TransitionRule> Rules { get; set; } // Conditions that must be met
public List<TransitionAction> Actions { get; set; } // Side effects on transition
}
public class TransitionRule
{
public RuleType Type { get; set; }
public string Field { get; set; }
public string Operator { get; set; }
public string Value { get; set; }
}
public enum RuleType
{
FieldRequired, // "Description must not be empty"
FieldValueEquals, // "Priority must be High or Urgent"
UserInRole, // "User must be a project admin"
CustomFieldCondition // Custom field value check
}
public class TransitionAction
{
public ActionType Type { get; set; }
public string Config { get; set; } // JSON configuration for the action
}
public enum ActionType
{
SetField,
SendNotification,
TriggerAutomation,
UpdateParentStatus,
LogAuditEntry
}
public enum StateCategory
{
Todo,
InProgress,
Review,
Done,
Cancelled
}
Example: Default Bug Workflow
State Machine Validator
C#
public class WorkflowEngine
{
private readonly IWorkflowRepository _repository;
private readonly INotificationService _notifications;
public async Task<TransitionResult> TransitionAsync(
Issue issue, Guid toStateId, Guid userId)
{
var workflow = await _repository
.GetWorkflowForIssueType(issue.ProjectId, issue.Type);
var currentState = workflow.States
.First(s => s.Id == issue.Status.StateId);
var transition = workflow.Transitions
.FirstOrDefault(t =>
t.FromStateId == currentState.Id &&
t.ToStateId == toStateId);
if (transition == null)
return TransitionResult.Fail("Invalid transition");
foreach (var rule in transition.Rules)
{
var satisfied = await EvaluateRuleAsync(rule, issue, userId);
if (!satisfied)
return TransitionResult.Fail(
$"Rule not satisfied: {rule.Type}");
}
var targetState = workflow.States
.First(s => s.Id == toStateId);
issue.Status = new IssueStatus
{
StateId = toStateId,
StateName = targetState.Name,
Category = targetState.Category
};
issue.UpdatedAt = DateTime.UtcNow;
if (targetState.Category == StateCategory.Done)
issue.CompletedAt = DateTime.UtcNow;
else if (targetState.Category == StateCategory.InProgress
&& issue.StartedAt == null)
issue.StartedAt = DateTime.UtcNow;
foreach (var action in transition.Actions)
{
await ExecuteActionAsync(action, issue, userId);
}
return TransitionResult.Ok();
}
private async Task<bool> EvaluateRuleAsync(
TransitionRule rule, Issue issue, Guid userId)
{
return rule.Type switch
{
RuleType.FieldRequired => IsFieldPopulated(
issue, rule.Field),
RuleType.FieldValueEquals => CheckFieldValue(
issue, rule.Field, rule.Operator, rule.Value),
RuleType.UserInRole => await CheckUserRole(
userId, issue.ProjectId, rule.Value),
_ => true
};
}
}
5. Kanban & Scrum Board Views
The board is the primary interface for most users. It visualizes issues as cards organized into columns, where each column represents a workflow state. The board must support two paradigms:
- Kanban Board: Continuous flow. All issues in the project (or matching a filter) are shown, grouped by status. There are no sprints. WIP (Work In Progress) limits can be set per column.
- Scrum Board: Sprint-scoped. Only issues assigned to the current sprint are shown. When the sprint ends, completed issues disappear and the board is reset for the next sprint.
Board Data Model
C#
public class Board
{
public Guid Id { get; set; }
public string Name { get; set; }
public BoardType Type { get; set; } // Kanban or Scrum
public Guid ProjectId { get; set; }
public List<BoardColumn> Columns { get; set; }
public BoardFilter Filter { get; set; } // Which issues to show
public Dictionary<Guid, int> WipLimits { get; set; } // Column ID -> limit
}
public class BoardColumn
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid WorkflowStateId { get; set; }
public int Order { get; set; }
public int? WipLimit { get; set; }
}
public class BoardFilter
{
public List<Guid> AssigneeIds { get; set; }
public List<string> Labels { get; set; }
public List<Guid> ComponentIds { get; set; }
public IssuePriority? MinPriority { get; set; }
public List<IssueType> IssueTypes { get; set; }
}
public enum BoardType
{
Kanban,
Scrum
}
Real-Time Board Synchronization
When a user drags a card from "In Progress" to "In Review," the system must:
- Validate the transition via the workflow engine.
- Update the issue in the database.
- Broadcast the change to all connected clients viewing the same board.
- Re-evaluate WIP limits and highlight violations.
This is implemented via WebSocket subscriptions. The server maintains a map of board subscriptions: Dictionary<Guid, HashSet<WebSocket>> where the key is the board ID. When an issue changes, the server publishes the change to all subscribers of that board.
C#
public class BoardHub : Hub
{
private static readonly ConcurrentDictionary<string, HashSet<string>>
_boardSubscriptions = new();
public async Task SubscribeToBoard(Guid boardId)
{
await Groups.AddToGroupAsync(
Context.ConnectionId, boardId.ToString());
}
public async Task MoveIssue(
Guid issueId, Guid fromColumnId, Guid toColumnId)
{
var result = await _workflowEngine.TransitionAsync(
issueId, toColumnId, Context.UserIdentifier);
if (result.IsSuccess)
{
var update = new BoardUpdate
{
Type = UpdateType.CardMoved,
IssueId = issueId,
FromColumn = fromColumnId,
ToColumn = toColumnId,
Timestamp = DateTime.UtcNow
};
await Clients.Group(
result.BoardId.ToString())
.SendAsync("IssueMoved", update);
}
}
}
WIP Limit Enforcement
WIP limits are a core Kanban practice. When a column exceeds its limit, the board visually highlights it (typically in red). The system should warn but not hard-block exceeding limits — this is a cultural norm in Kanban, not a hard constraint. The warning is displayed as a badge on the column header showing "5/3" (5 issues, limit of 3).
6. Sprint Planning & Backlog Management
For Scrum teams, the sprint is the fundamental time-boxed unit of work. The system must support the full sprint lifecycle: planning, daily standups (via board view), review, and retrospective (via reports).
Sprint Data Model
C#
public class Sprint
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid ProjectId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public SprintState State { get; set; } // Planned, Active, Completed
public string Goal { get; set; }
public int? CapacityPoints { get; set; }
public int? CapacityHours { get; set; }
}
public enum SprintState
{
Planned,
Active,
Completed
}
Backlog Ordering
The backlog is a prioritized list of issues not yet assigned to any sprint. The ordering is maintained via an integer Order field on each issue. When a user drags an issue up or down in the backlog, the system updates the Order field for the affected issues. To avoid re-numbering all issues on every drag, we use a fractional indexing scheme: when an issue is dragged between issues with orders 100 and 200, it gets order 150. When the gap becomes too small, we re-batch a segment of issues.
C#
public class BacklogService
{
public async Task ReorderIssueAsync(
Guid issueId, Guid? beforeIssueId, Guid? afterIssueId)
{
var beforeOrder = beforeIssueId.HasValue
? (await _repo.GetIssue(beforeIssueId.Value)).Order ?? 0
: int.MinValue;
var afterOrder = afterIssueId.HasValue
? (await _repo.GetIssue(afterIssueId.Value)).Order ?? 0
: int.MaxValue;
var newOrder = (beforeOrder + afterOrder) / 2;
// If the gap is too small, re-batch
if (afterOrder - beforeOrder < 2)
{
await RebatchOrdersAsync(
issue.ProjectId, beforeIssueId, afterIssueId);
newOrder = (beforeOrder + afterOrder) / 2;
}
issue.Order = newOrder;
await _repo.UpdateIssueAsync(issue);
}
}
Sprint Planning Flow
7. Priority & Severity Classification
Priority and severity are often confused but serve different purposes. Priority indicates how urgently the issue should be addressed relative to other work. Severity (primarily for bugs) indicates the impact of the defect on the system.
| Priority | Meaning | Response Time | Color |
|---|---|---|---|
| Urgent | Critical business impact, must be fixed immediately | Immediate | Red |
| High | Significant impact, should be fixed this sprint | Within 24 hours | Orange |
| Medium | Moderate impact, fix when capacity allows | Within 1 week | Yellow |
| Low | Minor impact, schedule for future | Within 1 month | Blue |
| No Priority | Unprioritized, needs triage | N/A | Gray |
| Severity | Meaning | Example |
|---|---|---|
| Critical | System down, data loss, security breach | Payment processing failing for all users |
| Major | Major feature broken, no workaround | Search returns wrong results |
| Minor | Minor feature broken, workaround exists | Export button downloads wrong format |
| Trivial | Cosmetic, no functional impact | Typo in error message |
The system should allow priority to be set by anyone but restrict severity setting to QA engineers and reporters. An urgency/impact matrix (often called a "risk matrix") can be displayed on the issue page to help teams understand the classification visually.
8. Assignment Strategies & Round-Robin
Issue assignment is one of the most impactful features for team productivity. The system should support multiple assignment modes:
- Manual assignment: The reporter or project lead explicitly assigns the issue to a team member.
- Round-robin: Issues are distributed evenly across team members in a rotating fashion.
- Load-based: Issues are assigned to the team member with the fewest open issues.
- Skill-based: Issues are assigned based on labels/components matching team members' expertise areas.
- Auto-assign on status change: When an issue moves to "In Review," automatically assign it to the code reviewer.
C#
public class AssignmentService
{
public async Task<Guid?> AutoAssignAsync(
Issue issue, AssignmentStrategy strategy)
{
var members = await _repo.GetProjectMembers(
issue.ProjectId);
return strategy switch
{
AssignmentStrategy.RoundRobin
=> await GetRoundRobinAssigneeAsync(
issue.ProjectId, members),
AssignmentStrategy.LeastLoaded
=> await GetLeastLoadedAssigneeAsync(
issue.ProjectId, members),
AssignmentStrategy.SkillBased
=> await GetSkillBasedAssigneeAsync(
issue, members),
_ => null
};
}
private async Task<Guid> GetRoundRobinAssigneeAsync(
Guid projectId, List<TeamMember> members)
{
var lastIndex = await _cache.GetAsync<int>(
$"rr_index:{projectId}");
var nextIndex = (lastIndex + 1) % members.Count;
await _cache.SetAsync(
$"rr_index:{projectId}", nextIndex);
return members[nextIndex].UserId;
}
private async Task<Guid> GetLeastLoadedAssigneeAsync(
Guid projectId, List<TeamMember> members)
{
var openCounts = await _repo.GetOpenIssueCounts(
projectId, members.Select(m => m.UserId).ToList());
return openCounts.OrderBy(kvp => kvp.Value).First().Key;
}
}
The round-robin counter is stored in Redis (or an in-memory cache for single-server deployments) with a key like rr_index:{projectId}. For distributed deployments, we use Redis INCR to atomically increment and wrap around. The least-loaded strategy queries the database for the count of open issues per assignee, which is efficient with a composite index on (ProjectId, AssigneeId, Status.Category).
9. Issue Templates & Custom Fields
Issue templates standardize how information is reported, reducing back-and-forth between reporters and assignees. A bug template might require steps to reproduce, expected behavior, and actual behavior. A story template might require user story format and acceptance criteria.
Template Definition
C#
public class IssueTemplate
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid ProjectId { get; set; }
public IssueType Type { get; set; }
public string TitlePreset { get; set; } // e.g., "[Bug] "
public string DescriptionTemplate { get; set; } // Markdown with placeholders
public Dictionary<string, object> DefaultValues { get; set; }
public List<TemplateField> RequiredFields { get; set; }
}
public class TemplateField
{
public string FieldName { get; set; }
public string DisplayName { get; set; }
public FieldType Type { get; set; }
public bool IsRequired { get; set; }
public string Placeholder { get; set; }
public List<string> Options { get; set; } // For dropdowns
}
public enum FieldType
{
Text,
TextArea,
Number,
Dropdown,
MultiSelect,
Date,
User,
RichText
}
Custom Fields
Custom fields extend the issue model with project-specific data. A hardware team might add " Firmware Version" and "Device Model." A legal team might add "Contract Type" and "Jurisdiction." Custom fields are stored as a JSON dictionary on the issue to avoid schema proliferation.
C#
public class CustomFieldDefinition
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Key { get; set; } // snake_case identifier
public CustomFieldType Type { get; set; }
public bool IsRequired { get; set; }
public string DefaultValue { get; set; }
public List<CustomFieldOption> Options { get; set; }
public string ValidationRegex { get; set; } // Optional regex validation
public Guid ProjectId { get; set; }
}
public enum CustomFieldType
{
Text,
Number,
SingleSelect,
MultiSelect,
Date,
DateTime,
Url,
User,
Formula // Computed from other fields
}
public class CustomFieldOption
{
public Guid Id { get; set; }
public string Label { get; set; }
public string Color { get; set; }
public int Order { get; set; }
}
11. Labels, Components & Saved Views
Labels and components provide two dimensions of categorization. Labels are free-form, user-created tags (e.g., "regression," "ui," "performance"). Components are structured, admin-defined categories (e.g., "Authentication," "Payments," "Notifications").
Label and Component Models
C#
public class Label
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Color { get; set; } // Hex color
public Guid ProjectId { get; set; }
}
public class Component
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public Guid? LeadUserId { get; set; } // Component lead
public Guid ProjectId { get; set; }
public List<Guid> MemberIds { get; set; } // Team members
}
Saved Views (Filters)
A saved view captures a set of filter criteria and display preferences (columns, grouping, sorting). Users can save their most-used queries and share them with the team.
C#
public class SavedView
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid OwnerId { get; set; }
public Guid ProjectId { get; set; }
public FilterCriteria Filter { get; set; }
public ViewDisplayOptions Display { get; set; }
public bool IsShared { get; set; }
}
public class FilterCriteria
{
public List<IssueType> IssueTypes { get; set; }
public List<IssueStatus> Statuses { get; set; }
public List<IssuePriority> Priorities { get; set; }
public List<Guid> AssigneeIds { get; set; }
public List<string> Labels { get; set; }
public List<Guid> ComponentIds { get; set; }
public DateTime? CreatedAfter { get; set; }
public DateTime? CreatedBefore { get; set; }
public DateTime? UpdatedAfter { get; set; }
public string TextSearch { get; set; }
public string CustomQuery { get; set; } // JQL-like string
}
public class ViewDisplayOptions
{
public ViewType Type { get; set; } // Board, List, Timeline, Calendar
public string GroupBy { get; set; } // "assignee", "priority", "status"
public string SortBy { get; set; }
public SortDirection SortDirection { get; set; }
public List<string> VisibleColumns { get; set; }
}
public enum ViewType
{
Board,
List,
Timeline,
Calendar
}
12. Filters, Search & Query Engine
The search and query engine powers everything from the quick-search bar to saved filters to the JQL-like advanced query language. At scale, this requires a purpose-built search index.
Query Language (JQL-like)
text
-- Simple queries
status = "In Progress" AND assignee = currentUser()
priority in (Urgent, High) AND type = Bug
labels = "regression" AND created >= -7d
-- Complex queries
project = PROJ AND (
(status = "In Review" AND assignee = "alice@co.com")
OR (status = "Done" AND resolved >= startOfWeek())
) AND component = "Payments"
ORDER BY priority ASC, updated DESC
Search Index Architecture
We use a dual-write architecture. When an issue is created or updated, it is written to PostgreSQL (the primary store) and simultaneously indexed in Elasticsearch (or Meilisearch for simpler deployments). The search index stores a denormalized version of the issue with all fields flattened for efficient full-text search and filtering.
C#
public class SearchIndexDocument
{
public string Id { get; set; } // Issue GUID as string
public string Identifier { get; set; } // "PROJ-123"
public string Title { get; set; }
public string Description { get; set; }
public string Type { get; set; }
public string Status { get; set; }
public string StatusCategory { get; set; }
public string Priority { get; set; }
public string Severity { get; set; }
public string AssigneeId { get; set; }
public string AssigneeName { get; set; }
public string ReporterId { get; set; }
public string ProjectId { get; set; }
public List<string> Labels { get; set; }
public List<string> ComponentIds { get; set; }
public string SprintId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public int StoryPoints { get; set; }
public Dictionary<string, string> CustomFields { get; set; }
// Computed fields for filtering
public bool IsOverdue { get; set; }
public int DaysInCurrentStatus { get; set; }
}
public class IssueSearchService
{
private readonly ISearchIndex _index;
public async Task<SearchResult> SearchAsync(
SearchQuery query, int page = 1, int pageSize = 50)
{
var filter = BuildFilter(query);
var sort = BuildSort(query.SortBy, query.SortDirection);
var results = await _index.SearchAsync(
query.Text,
filter,
sort,
page,
pageSize);
return new SearchResult
{
Issues = results.Items,
TotalCount = results.TotalHits,
Page = page,
PageSize = pageSize
};
}
}
13. Reporting & Dashboards
Reports transform raw issue data into actionable insights. The system must provide several standard report types and support custom dashboard creation.
Burndown Chart
The burndown chart tracks remaining work (story points or issue count) over the sprint duration. The ideal line shows linear progress from total points to zero. The actual line shows the real remaining work, accounting for scope changes (issues added/removed mid-sprint).
C#
public class BurndownReport
{
public Guid SprintId { get; set; }
public List<BurndownDataPoint> IdealLine { get; set; }
public List<BurndownDataPoint> ActualLine { get; set; }
public List<ScopeChange> ScopeChanges { get; set; }
public int TotalPoints { get; set; }
public int CompletedPoints { get; set; }
public int RemainingPoints { get; set; }
public double PercentComplete { get; set; }
public DateTime SprintEnd { get; set; }
public TimeSpan TimeRemaining { get; set; }
}
public class BurndownDataPoint
{
public DateTime Date { get; set; }
public int RemainingPoints { get; set; }
public int RemainingIssues { get; set; }
}
// Calculated by snapshotting remaining points daily
// and interpolating for intra-day views
public class BurndownService
{
public async Task<BurndownReport> GenerateAsync(Guid sprintId)
{
var sprint = await _repo.GetSprint(sprintId);
var snapshots = await _repo.GetBurndownSnapshots(sprintId);
var idealLine = GenerateIdealLine(
sprint.StartDate, sprint.EndDate,
snapshots.First().RemainingPoints);
var actualLine = snapshots.Select(s => new BurndownDataPoint
{
Date = s.Date,
RemainingPoints = s.RemainingPoints,
RemainingIssues = s.RemainingIssueCount
}).ToList();
return new BurndownReport
{
SprintId = sprintId,
IdealLine = idealLine,
ActualLine = actualLine,
ScopeChanges = await GetScopeChanges(sprintId)
};
}
}
Velocity Chart
The velocity chart shows the number of story points completed per sprint over time. This historical data helps teams forecast how much work they can take on in future sprints.
Cycle Time Report
Cycle time measures how long an issue takes from "In Progress" to "Done." The system calculates this by tracking state transitions and computing the elapsed time in each state. A histogram shows the distribution, and percentile lines (p50, p85, p95) help teams understand their typical delivery speed.
C#
public class CycleTimeReport
{
public double P50Days { get; set; }
public double P85Days { get; set; }
public double P95Days { get; set; }
public double AverageDays { get; set; }
public List<CycleTimeDataPoint> Histogram { get; set; }
public List<CycleTimeTrend> Trend { get; set; } // Over time
}
public class CycleTimeService
{
public async Task<CycleTimeReport> CalculateAsync(
Guid projectId, DateTime from, DateTime to)
{
var transitions = await _repo
.GetStateTransitions(projectId, from, to);
var cycleTimes = transitions
.Where(t => t.ToCategory == StateCategory.Done)
.Select(t => (t.CompletedAt - t.StartedAt).TotalDays)
.OrderBy(d => d)
.ToList();
return new CycleTimeReport
{
P50Days = Percentile(cycleTimes, 50),
P85Days = Percentile(cycleTimes, 85),
P95Days = Percentile(cycleTimes, 95),
AverageDays = cycleTimes.Average()
};
}
}
Custom Dashboards
Dashboards are collections of configurable widgets. Each widget is a self-contained chart or table backed by a data source. Users can create personal dashboards or share team dashboards.
| Widget Type | Data Source | Description |
|---|---|---|
| Burndown Chart | Sprint snapshots | Remaining work over sprint time |
| Velocity Chart | Sprint completion data | Points completed per sprint |
| Cycle Time Histogram | State transition logs | Distribution of delivery times |
| Issue Count by Status | Current issue state | Pie/bar chart of issues per status |
| Issues by Priority | Current issue data | Stacked bar by priority and status |
| Team Workload | Assignment data | Open issues per team member |
| Overdue Issues | Due dates vs today | List of past-due issues |
| Recent Activity | Activity feed | Stream of recent changes |
14. Roadmapping & Long-Term Planning
Roadmaps operate at a higher level than sprints. They map epics (or initiatives) to a timeline, giving leadership visibility into the product direction over months or quarters.
C#
public class RoadmapEntry
{
public Guid Id { get; set; }
public Guid EpicId { get; set; }
public string Name { get; set; }
public string Color { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public RoadmapStatus Status { get; set; }
public int ProgressPercent { get; set; } // Derived from child issues
public List<RoadmapDependency> Dependencies { get; set; }
}
public enum RoadmapStatus
{
Planned,
InProgress,
AtRisk,
Completed,
Cancelled
}
The roadmap view displays epics as horizontal bars on a timeline (similar to a Gantt chart). Dependencies between epics are shown as arrows. The progress bar on each epic is computed from the completion percentage of its child stories. When a child story's due date changes and pushes an epic past its end date, the epic's status automatically changes to "At Risk."
15. Git Integration
Git integration bridges the gap between issue tracking and code development. Developers should be able to create branches, link commits, and associate pull requests directly from the issue tracker.
Integration Flow
C#
public class GitIntegrationService
{
public async Task<BranchResult> CreateBranchFromIssueAsync(
Guid issueId, string repository)
{
var issue = await _repo.GetIssue(issueId);
var identifier = issue.Identifier.ToLower(); // "proj-123"
var slug = Slugify(issue.Title).Substring(0, 50);
var branchName = $"{GetBranchPrefix(issue.Type)}/{identifier}-{slug}";
// e.g., "bugfix/proj-123-fix-payment-timeout"
// e.g., "feature/proj-456-add-dark-mode"
var result = await _gitProvider.CreateBranchAsync(
repository, branchName, await GetDefaultBranchAsync(repository));
// Store the association
await _repo.LinkBranchAsync(issueId, branchName, repository);
// Auto-transition issue if workflow allows
if (issue.Status.Category == StateCategory.Todo)
{
await _workflowEngine.TransitionAsync(
issue, InProgressStateId, issue.AssigneeId.Value);
}
return new BranchResult { BranchName = branchName };
}
public async Task ProcessPushEventAsync(PushWebhook payload)
{
foreach (var commit in payload.Commits)
{
var issueIdentifiers = ExtractIssueIdentifiers(commit.Message);
// e.g., ["PROJ-123", "PROJ-456"]
foreach (var identifier in issueIdentifiers)
{
var issue = await _repo.GetIssueByIdentifier(identifier);
if (issue != null)
{
await _repo.LinkCommitAsync(
issue.Id, commit.Sha, commit.Message,
commit.Author, commit.Url);
}
}
}
}
private string GetBranchPrefix(IssueType type) => type switch
{
IssueType.Bug => "bugfix",
IssueType.Story => "feature",
IssueType.Task => "chore",
_ => "issue"
};
}
Commit message parsing follows the convention PROJ-123: Fix payment timeout. The system also recognizes keywords like "fixes PROJ-123" and "closes PROJ-123" to auto-transition issues when commits are merged to the default branch.
16. CI/CD Integration
CI/CD integration connects deployments to issues. When a build is triggered by a commit linked to an issue, the issue page shows the build status. When a deployment succeeds, linked issues can be auto-transitioned to "Deployed."
C#
public class CICDIntegration
{
// Webhook handler for build events
public async Task HandleBuildEventAsync(BuildWebhook payload)
{
var commits = payload.Commits;
var issueIds = new List<Guid>();
foreach (var commit in commits)
{
var linked = await _repo.GetIssuesLinkedToCommit(
commit.Sha);
issueIds.AddRange(linked);
}
foreach (var issueId in issueIds.Distinct())
{
var buildRecord = new BuildRecord
{
IssueId = issueId,
BuildId = payload.BuildId,
Status = payload.Status, // Pending, Running, Success, Failed
Url = payload.BuildUrl,
Branch = payload.Branch,
CommitSha = payload.CommitSha,
StartedAt = payload.StartedAt,
CompletedAt = payload.CompletedAt
};
await _repo.SaveBuildRecordAsync(buildRecord);
// Notify connected clients
await _hub.Clients.Group($"issue:{issueId}")
.SendAsync("BuildUpdated", buildRecord);
}
}
}
17. Notification System
The notification system keeps team members informed about relevant changes. It must support multiple delivery channels and respect user preferences.
Notification Channels
| Channel | Use Case | Delivery |
|---|---|---|
| In-App | Real-time updates while working | WebSocket push, persisted in DB |
| Daily digest or immediate for urgent | SMTP/SendGrid, batched or immediate | |
| Slack | Team channel updates | Slack Webhook or Slack App |
| Microsoft Teams | Enterprise team updates | Teams Webhook |
| Webhook (custom) | Custom integrations | Outbound HTTP POST |
Notification Preferences
C#
public class NotificationPreference
{
public Guid UserId { get; set; }
public NotificationEvent EventType { get; set; }
public bool InAppEnabled { get; set; }
public bool EmailEnabled { get; set; }
public bool SlackEnabled { get; set; }
public EmailFrequency EmailFrequency { get; set; } // Immediate, Daily, Weekly
}
public enum NotificationEvent
{
IssueAssigned,
IssueStatusChanged,
IssueCommented,
IssueMentioned,
IssueBlocked,
SprintStarted,
SprintCompleted,
BuildFailed,
BuildSucceeded,
DeploymentComplete,
SLABreached
}
public class NotificationService
{
public async Task SendAsync(
NotificationEvent evt, Guid userId, NotificationData data)
{
var prefs = await _repo.GetPreferences(userId, evt);
if (prefs.InAppEnabled)
{
var notification = new InAppNotification
{
UserId = userId,
EventType = evt,
Data = data,
ReadAt = null,
CreatedAt = DateTime.UtcNow
};
await _repo.SaveNotificationAsync(notification);
await _hub.Clients.User(userId.ToString())
.SendAsync("Notification", notification);
}
if (prefs.EmailEnabled)
{
if (prefs.EmailFrequency == EmailFrequency.Immediate)
{
await _emailQueue.EnqueueAsync(
userId, evt, data);
}
// Daily/weekly digests are handled by a background job
}
if (prefs.SlackEnabled)
{
await _slackService.SendAsync(userId, evt, data);
}
}
}
The email digest system batches notifications into a single email sent at a configurable interval. A background job runs every hour, collects unread notifications for each user with digest preferences, groups them by project, and sends a single HTML email with a summary.
18. SLA Tracking & Time Tracking
SLA (Service Level Agreement) tracking ensures that issues are resolved within defined timeframes. Time tracking allows team members to log hours spent on issues for capacity planning and billing.
SLA Policy Model
C#
public class SLAPolicy
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid ProjectId { get; set; }
public SLACondition Conditions { get; set; }
public SLATargets Targets { get; set; }
public bool IsActive { get; set; }
}
public class SLACondition
{
public List<IssueType> IssueTypes { get; set; }
public List<IssuePriority> Priorities { get; set; }
public List<Guid> ComponentIds { get; set; }
}
public class SLATargets
{
public TimeSpan FirstResponseTime { get; set; } // Time to first comment/assignment
public TimeSpan ResolutionTime { get; set; } // Time to move to "Done"
public TimeSpan? AcknowledgmentTime { get; set; } // Time to move from "Open" to "In Progress"
}
public class SLAStatus
{
public Guid IssueId { get; set; }
public Guid PolicyId { get; set; }
public DateTime? FirstResponseAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
public DateTime? ResolvedAt { get; set; }
public SLABreachStatus Status { get; set; } // OnTrack, AtRisk, Breached
public TimeSpan? TimeRemaining { get; set; }
}
public enum SLABreachStatus
{
OnTrack,
AtRisk, // < 25% time remaining
Breached // Time expired
}
Time Tracking
C#
public class TimeEntry
{
public Guid Id { get; set; }
public Guid IssueId { get; set; }
public Guid UserId { get; set; }
public decimal Hours { get; set; }
public string Description { get; set; }
public DateTime Date { get; set; }
public TimeEntryType Type { get; set; } // Work, Review, Meeting
}
public class TimeTrackingService
{
public async Task<TimeSummary> GetIssueTimeSummaryAsync(
Guid issueId)
{
var entries = await _repo.GetTimeEntries(issueId);
return new TimeSummary
{
TotalHours = entries.Sum(e => e.Hours),
EstimatedHours = entries.FirstOrDefault(
e => e.Type == TimeEntryType.Work)?.Hours ?? 0,
LoggedHours = entries
.Where(e => e.Type != TimeEntryType.Work)
.Sum(e => e.Hours),
ByUser = entries
.GroupBy(e => e.UserId)
.ToDictionary(g => g.Key, g => g.Sum(e => e.Hours))
};
}
}
19. Custom Automation Rules
Automation rules allow teams to define trigger-condition-action patterns that execute automatically when issues change. This eliminates repetitive manual work and enforces process consistency.
Rule Definition
C#
public class AutomationRule
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid ProjectId { get; set; }
public bool IsActive { get; set; }
public RuleTrigger Trigger { get; set; }
public List<RuleCondition> Conditions { get; set; }
public List<RuleAction> Actions { get; set; }
public Guid? CreatedBy { get; set; }
}
public class RuleTrigger
{
public TriggerType Type { get; set; }
public string FieldName { get; set; } // For field change triggers
}
public enum TriggerType
{
IssueCreated,
IssueUpdated,
StatusChanged,
AssigneeChanged,
CommentAdded,
SprintChanged,
LabelAdded,
LabelRemoved,
LinkedIssueAdded,
ScheduledDaily, // Cron-like triggers
ScheduledWeekly
}
public class RuleCondition
{
public string Field { get; set; }
public string Operator { get; set; } // equals, not_equals, contains, greater_than
public string Value { get; set; }
public string Logic { get; set; } // AND, OR
}
public class RuleAction
{
public ActionType Type { get; set; }
public Dictionary<string, string> Parameters { get; set; }
}
public enum ActionType
{
SetFieldValue,
TransitionToStatus,
AddLabel,
RemoveLabel,
AssignToUser,
AssignToRole,
SendNotification,
AddComment,
CreateSubTask,
LinkToEpic,
RemoveFromSprint,
AddToSprint
}
Example Rules
| Rule Name | Trigger | Condition | Action |
|---|---|---|---|
| Auto-assign reviewer | Status → "In Review" | Assignee is not null | Assign to code review group (round-robin) |
| Blocker escalation | Priority → "Urgent" | Type = Bug | Add label "critical", notify team lead |
| Stale issue warning | Scheduled daily | No update in 14 days, status ≠ Done | Add comment "This issue has been stale for 14 days" |
| Sprint cleanup | Sprint completed | Status ≠ Done | Move to backlog |
| Duplicate closure | Link type → "Duplicates" | Target is "Done" | Transition source to "Closed" |
Rule Engine Implementation
C#
public class AutomationEngine
{
private readonly IRuleRepository _ruleRepo;
private readonly IWorkflowEngine _workflow;
public async Task ProcessIssueEventAsync(
Issue issue, TriggerType trigger,
string changedField = null)
{
var rules = await _ruleRepo.GetActiveRulesForProject(
issue.ProjectId);
foreach (var rule in rules.Where(r =>
r.Trigger.Type == trigger))
{
if (trigger == TriggerType.IssueUpdated
&& rule.Trigger.FieldName != null
&& rule.Trigger.FieldName != changedField)
continue;
if (await EvaluateConditionsAsync(
rule.Conditions, issue))
{
await ExecuteActionsAsync(
rule.Actions, issue);
}
}
}
private async Task<bool> EvaluateConditionsAsync(
List<RuleCondition> conditions, Issue issue)
{
if (!conditions.Any()) return true;
var results = new List<bool>();
foreach (var condition in conditions)
{
var value = GetFieldValue(issue, condition.Field);
var matches = EvaluateOperator(
value, condition.Operator, condition.Value);
results.Add(matches);
}
// Simple AND logic for now; extend with grouping
return results.All(r => r);
}
}
20. Audit Log & Bulk Operations
The audit log is a complete, immutable record of every change made in the system. It answers the question "who changed what and when?" and is essential for compliance (SOC 2, GDPR) and debugging.
Audit Log Model
C#
public class AuditLogEntry
{
public long Id { get; set; } // Auto-increment for ordering
public Guid OrganizationId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ActorId { get; set; }
public string ActorEmail { get; set; }
public string ActorIpAddress { get; set; }
public AuditAction Action { get; set; }
public string EntityType { get; set; } // "Issue", "Project", "Sprint"
public string EntityId { get; set; }
public string EntityIdentifier { get; set; } // "PROJ-123"
public Dictionary<string, object> OldValues { get; set; }
public Dictionary<string, object> NewValues { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
public enum AuditAction
{
Created,
Updated,
Deleted,
StatusChanged,
Assigned,
Commented,
AttachmentUploaded,
AttachmentDeleted,
Viewed,
Exported,
BulkUpdated,
Imported
}
Bulk Operations
Bulk operations allow users to select multiple issues (via checkbox on the list view) and perform batch actions: move to sprint, change status, reassign, add labels, change priority, or delete. The API accepts a list of issue IDs and the operation to perform.
C#
public class BulkOperationRequest
{
public List<Guid> IssueIds { get; set; }
public BulkOperationType Operation { get; set; }
public Dictionary<string, object> Parameters { get; set; }
}
public class BulkOperationResult
{
public int TotalRequested { get; set; }
public int SuccessCount { get; set; }
public int FailureCount { get; set; }
public List<BulkOperationError> Errors { get; set; }
}
// Each bulk operation is wrapped in a single transaction
// to ensure atomicity: either all issues are updated or none.
// The audit log records the bulk operation as a single entry
// with the full list of affected entity IDs.
21. Import & Export, API & Webhooks
Import from Other Tools
Migration from Jira, Asana, Trello, or CSV is a critical adoption feature. The import pipeline:
- Extract: Pull data from the source tool via its API (e.g., Jira REST API, Trello API).
- Transform: Map source fields to our schema. Handle workflow mapping, user mapping (by email), and custom field mapping.
- Load: Batch-insert into our database, preserving relationships (parent-child, links) and attachment URLs.
RESTful API Design
text
Base URL: https://api.trackr.io/v1
Issues
GET /projects/{projectId}/issues List issues (with filters)
POST /projects/{projectId}/issues Create issue
GET /issues/{issueId} Get issue
PATCH /issues/{issueId} Update issue
DELETE /issues/{issueId} Delete issue
GET /issues/{issueId}/comments List comments
POST /issues/{issueId}/comments Add comment
POST /issues/{issueId}/transitions Transition status
POST /issues/{issueId}/assign Assign issue
Sprints
GET /projects/{projectId}/sprints List sprints
POST /projects/{projectId}/sprints Create sprint
POST /sprints/{sprintId}/issues Add issues to sprint
Boards
GET /projects/{projectId}/boards List boards
GET /boards/{boardId} Get board with columns and issues
Search
POST /search Advanced search (JQL-like)
Webhooks
GET /webhooks List webhooks
POST /webhooks Create webhook
DELETE /webhooks/{webhookId} Delete webhook
Users
GET /organizations/{orgId}/members List members
GET /users/me Current user profile
Webhook System
C#
public class WebhookSubscription
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string Url { get; set; }
public List<WebhookEvent> Events { get; set; }
public string Secret { get; set; } // For HMAC signature verification
public bool IsActive { get; set; }
public DateTime? LastTriggeredAt { get; set; }
public int FailureCount { get; set; } // Circuit breaker
}
public class WebhookDelivery
{
public Guid Id { get; set; }
public Guid WebhookId { get; set; }
public string EventType { get; set; }
public string Payload { get; set; } // JSON
public int HttpStatusCode { get; set; }
public string ResponseBody { get; set; }
public TimeSpan ResponseTime { get; set; }
public DateTime DeliveredAt { get; set; }
public bool Success { get; set; }
public int AttemptNumber { get; set; } // Up to 3 retries with exponential backoff
}
public class WebhookService
{
public async Task DeliverAsync(
WebhookSubscription subscription, string eventType,
object payload)
{
var json = JsonSerializer.Serialize(payload);
var signature = ComputeHMAC(subscription.Secret, json);
var request = new HttpRequestMessage(HttpMethod.Post,
subscription.Url);
request.Headers.Add("X-Webhook-Signature", signature);
request.Headers.Add("X-Webhook-Event", eventType);
request.Content = new StringContent(json,
Encoding.UTF8, "application/json");
var stopwatch = Stopwatch.StartNew();
var response = await _httpClient.SendAsync(request);
stopwatch.Stop();
var delivery = new WebhookDelivery
{
WebhookId = subscription.Id,
EventType = eventType,
Payload = json,
HttpStatusCode = (int)response.StatusCode,
ResponseTime = stopwatch.Elapsed,
DeliveredAt = DateTime.UtcNow,
Success = response.IsSuccessStatusCode
};
await _repo.SaveDeliveryAsync(delivery);
if (!delivery.Success)
{
await HandleDeliveryFailureAsync(
subscription, delivery);
}
}
}
22. Mobile App Considerations
A mobile app for the issue tracker should focus on consumption and quick actions rather than full feature parity with the web app. Key mobile features:
- Push notifications: Use FCM (Android) and APNs (iOS) for real-time push notifications when issues are assigned, commented on, or status changes.
- Offline support: Cache recently viewed issues for offline reading. Queue updates (comments, status changes) for sync when connectivity returns.
- Quick actions: Swipe to change status, quick-assign from a list, voice-to-text for comments.
- Camera integration: Direct screenshot capture to attach to issues (useful for field QA).
C#
// Shared API client for mobile and web
public class TrackrApiClient
{
private readonly HttpClient _http;
private readonly IAuthTokenStore _tokenStore;
public async Task<IssueListResponse> GetIssuesAsync(
IssueFilter filter, int page = 1)
{
var query = BuildQueryString(filter);
var response = await _http.GetAsync(
$"/v1/projects/{filter.ProjectId}/issues?{query}&page={page}");
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<IssueListResponse>();
}
}
// Offline-first model for mobile
public class OfflineIssueCache
{
public async Task CacheIssueAsync(Issue issue)
{
var json = JsonSerializer.Serialize(issue);
await _secureStorage.SetAsync(
$"issue:{issue.Id}", json);
}
public async Task QueueUpdateAsync(IssueUpdate update)
{
var pending = await GetPendingUpdates();
pending.Add(update);
await _secureStorage.SetAsync(
"pending_updates", JsonSerializer.Serialize(pending));
}
}
23. High-Level Architecture & Data Model
System Architecture
Core Database Schema
SQL
-- Organizations and projects
CREATE TABLE organizations (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE projects (
id UUID PRIMARY KEY,
organization_id UUID REFERENCES organizations(id),
name VARCHAR(255) NOT NULL,
key VARCHAR(10) UNIQUE NOT NULL, -- "PROJ"
configuration JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Issues (the core table)
CREATE TABLE issues (
id UUID PRIMARY KEY,
identifier VARCHAR(50) UNIQUE NOT NULL, -- "PROJ-123"
project_id UUID REFERENCES projects(id),
type VARCHAR(20) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
status_state_id UUID,
status_name VARCHAR(100),
status_category VARCHAR(20),
priority VARCHAR(20),
severity VARCHAR(20),
assignee_id UUID,
reporter_id UUID,
parent_id UUID REFERENCES issues(id),
epic_id UUID REFERENCES issues(id),
sprint_id UUID,
story_points INTEGER,
order_index FLOAT,
custom_fields JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
started_at TIMESTAMP,
completed_at TIMESTAMP,
due_date TIMESTAMP
);
-- Performance indexes
CREATE INDEX idx_issues_project ON issues(project_id);
CREATE INDEX idx_issues_assignee ON issues(assignee_id)
WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_issues_sprint ON issues(sprint_id)
WHERE sprint_id IS NOT NULL;
CREATE INDEX idx_issues_status ON issues(project_id, status_category);
CREATE INDEX idx_issues_epic ON issues(epic_id)
WHERE epic_id IS NOT NULL;
CREATE INDEX idx_issues_parent ON issues(parent_id)
WHERE parent_id IS NOT NULL;
CREATE INDEX idx_issues_updated ON issues(updated_at DESC);
CREATE INDEX idx_issues_identifier ON issues(identifier);
-- Sprints
CREATE TABLE sprints (
id UUID PRIMARY KEY,
project_id UUID REFERENCES projects(id),
name VARCHAR(255) NOT NULL,
start_date TIMESTAMP,
end_date TIMESTAMP,
state VARCHAR(20) DEFAULT 'planned',
goal TEXT,
capacity_points INTEGER
);
-- Comments
CREATE TABLE comments (
id UUID PRIMARY KEY,
issue_id UUID REFERENCES issues(id),
parent_comment_id UUID REFERENCES comments(id),
author_id UUID NOT NULL,
body TEXT NOT NULL,
mentions JSONB DEFAULT '[]',
created_at TIMESTAMP DEFAULT NOW(),
edited_at TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_comments_issue ON comments(issue_id, created_at);
-- Activity log
CREATE TABLE activity_events (
id BIGSERIAL PRIMARY KEY,
issue_id UUID REFERENCES issues(id),
event_type VARCHAR(50) NOT NULL,
actor_id UUID,
data JSONB DEFAULT '{}',
timestamp TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_activity_issue ON activity_events(issue_id, timestamp DESC);
CREATE INDEX idx_activity_timestamp ON activity_events(timestamp DESC);
-- Audit log (append-only)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
organization_id UUID,
project_id UUID,
actor_id UUID,
actor_email VARCHAR(255),
action VARCHAR(50),
entity_type VARCHAR(50),
entity_id VARCHAR(100),
old_values JSONB,
new_values JSONB,
timestamp TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_audit_org ON audit_log(organization_id, timestamp DESC);
CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id);
-- Attachments
CREATE TABLE attachments (
id UUID PRIMARY KEY,
issue_id UUID REFERENCES issues(id),
file_name VARCHAR(500),
content_type VARCHAR(100),
size_bytes BIGINT,
storage_url TEXT,
thumbnail_url TEXT,
uploaded_by UUID,
uploaded_at TIMESTAMP DEFAULT NOW()
);
24. Security, Compliance & Cost Estimation
Security Architecture
- Authentication: OAuth 2.0 / OIDC with support for Google, GitHub, and SAML/SSO for enterprise customers.
- Authorization: Role-Based Access Control (RBAC) with project-level roles: Admin, Member, Viewer, and custom roles.
- API Security: JWT tokens with short expiry (15 min) and refresh tokens (30 days). API keys for service-to-service communication with rate limiting.
- Data encryption: AES-256 at rest (database and S3), TLS 1.3 in transit. Field-level encryption for sensitive custom fields.
- Rate limiting: Per-user and per-organization rate limits using token bucket algorithm. API: 1000 requests/minute per user. Webhooks: 100 deliveries/minute per subscription.
RBAC Model
C#
public class Permission
{
public string Resource { get; set; } // "issue", "project", "sprint"
public string Action { get; set; } // "create", "read", "update", "delete"
public string Scope { get; set; } // "own", "project", "organization"
}
public class Role
{
public string Name { get; set; }
public List<Permission> Permissions { get; set; }
}
// Predefined roles
// Admin: Full access to everything
// Member: Create/edit issues, manage own sprints
// Viewer: Read-only access
// Custom: Configurable per project
public class AuthorizationService
{
public async Task<bool> AuthorizeAsync(
Guid userId, string resource, string action,
Guid projectId)
{
var role = await GetProjectRoleAsync(userId, projectId);
return role.Permissions.Any(p =>
p.Resource == resource &&
p.Action == action &&
(p.Scope == "organization" ||
p.Scope == "project" ||
(p.Scope == "own" &&
await IsOwnedByAsync(resource, userId))));
}
}
Compliance
- SOC 2 Type II: Audit logging, access controls, encryption at rest/in transit, incident response procedures.
- GDPR: Right to erasure (soft delete with 30-day purge), data export (full account export to JSON/CSV), consent management, data processing agreements.
- Data residency: Support for region-specific data storage (US, EU, APAC) for enterprise customers.
Cost Estimation
| Component | Service | Monthly Cost (10K users) | Notes |
|---|---|---|---|
| Application Servers | 4x c5.xlarge (AWS) | $560 | API + WebSocket + background jobs |
| PostgreSQL | db.r6g.xlarge (RDS) | $480 | Multi-AZ, 500GB storage |
| Elasticsearch | 3x m5.large (OpenSearch) | $500 | Search index for 10M issues |
| Redis | cache.r6g.large (ElastiCache) | $150 | Sessions, caching, rate limiting |
| Object Storage | S3 | $50 | 1TB attachments, 500GB/month growth |
| CDN | CloudFront | $30 | Static assets, thumbnails |
| SES | $20 | 500K emails/month | |
| Monitoring | DataDog / CloudWatch | $200 | APM, logs, metrics, alerts |
| Load Balancer | ALB | $40 | Application load balancer |
| DNS | Route53 | $5 | Domain management |
| Total Infrastructure | ~$2,035/month | Before optimization and reserved instances | |
25. Testing Strategy & Interview Q&A
Testing Strategy
| Layer | Tool | Coverage Target | What to Test |
|---|---|---|---|
| Unit Tests | xUnit + Moq | 85% | Workflow engine, search query parser, SLA calculator, automation rules |
| Integration Tests | Testcontainers | 70% | Database queries, Elasticsearch indexing, Redis caching |
| API Tests | RestAssured / HttpClient | All endpoints | Request/response validation, auth, rate limiting |
| E2E Tests | Playwright | Critical paths | Issue creation, board drag-and-drop, sprint planning, search |
| Performance Tests | k6 / Gatling | N/A | Board load latency, search latency, bulk operations |
| Load Tests | k6 | N/A | 10K concurrent users, WebSocket fan-out, webhook delivery |
Sample Unit Test
C#
public class WorkflowEngineTests
{
[Fact]
public async Task TransitionAsync_ValidTransition_UpdatesIssueStatus()
{
// Arrange
var issue = CreateIssue(StateCategory.Todo);
var workflow = CreateBugWorkflow();
var engine = new WorkflowEngine(
Mock.Of<IWorkflowRepository>(r =>
r.GetWorkflowForIssueType(
It.IsAny<Guid>(), IssueType.Bug)
== Task.FromResult(workflow)),
Mock.Of<INotificationService>());
var inProgressState = workflow.States
.First(s => s.Category == StateCategory.InProgress);
// Act
var result = await engine.TransitionAsync(
issue, inProgressState.Id, Guid.NewGuid());
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(StateCategory.InProgress,
issue.Status.Category);
Assert.NotNull(issue.StartedAt);
}
[Fact]
public async Task TransitionAsync_InvalidTransition_ReturnsFailure()
{
var issue = CreateIssue(StateCategory.Done);
var workflow = CreateBugWorkflow();
var engine = new WorkflowEngine(
Mock.Of<IWorkflowRepository>(r =>
r.GetWorkflowForIssueType(
It.IsAny<Guid>(), IssueType.Bug)
== Task.FromResult(workflow)),
Mock.Of<INotificationService>());
var todoState = workflow.States
.First(s => s.Category == StateCategory.Todo);
var result = await engine.TransitionAsync(
issue, todoState.Id, Guid.NewGuid());
Assert.False(result.IsSuccess);
Assert.Contains("Invalid transition",
result.ErrorMessage);
}
[Fact]
public async Task TransitionAsync_MissingRequiredField_ReturnsFailure()
{
var issue = CreateIssue(StateCategory.Todo);
issue.Description = null; // Required field is empty
var workflow = CreateWorkflowWithRequiredDescription();
var engine = new WorkflowEngine(
Mock.Of<IWorkflowRepository>(r =>
r.GetWorkflowForIssueType(
It.IsAny<Guid>(), IssueType.Bug)
== Task.FromResult(workflow)),
Mock.Of<INotificationService>());
var inProgressState = workflow.States
.First(s => s.Category == StateCategory.InProgress);
var result = await engine.TransitionAsync(
issue, inProgressState.Id, Guid.NewGuid());
Assert.False(result.IsSuccess);
Assert.Contains("Rule not satisfied",
result.ErrorMessage);
}
}
Interview Q&A Deep Dive
Q1: How do you handle real-time board updates when thousands of users are viewing the same board?
A: We use WebSocket connections grouped by board ID. When an issue changes, we publish the update to all subscribers of that board. To scale beyond a single server, we use Redis Pub/Sub as a message bus between server instances. Each instance maintains local WebSocket connections and subscribes to a Redis channel for its boards. When a change occurs on instance A, it publishes to Redis, and instance B forwards it to its local WebSocket subscribers. For boards with very large audiences (>1000 viewers, like a company-wide all-hands board), we use a "read-through" approach where updates are batched every 500ms rather than forwarded individually.
Q2: How do you prevent race conditions when two users drag the same card simultaneously?
A: Every issue update includes an updatedAt timestamp as an optimistic concurrency token. When User A drags a card, the request includes the updatedAt value they last saw. If User B updated the issue between A reading it and A submitting the change, the timestamps won't match, and the server rejects the update with a conflict error (HTTP 409). The frontend then re-fetches the issue and prompts the user to retry. In practice, conflicts are rare (<0.1% of updates) because the window is very small.
Q3: How would you design the JQL query parser?
A: The parser follows a standard compiler pipeline: Lexer → Parser → AST → SQL/Elasticsearch Query. The lexer tokenizes the input string into tokens (identifiers, operators, values, keywords). The parser builds an Abstract Syntax Tree (AST) representing the logical structure. The AST is then translated into an Elasticsearch bool query with must, should, and filter clauses. For simple queries (single field equality), we can bypass Elasticsearch and query PostgreSQL directly with a parameterized WHERE clause.
Q4: How do you handle the N+1 query problem when loading a board with 500 issues and their assignees?
A: We use a single query with a LEFT JOIN to load issues and their assignee names in one round trip. The board API endpoint constructs a query like SELECT i.*, u.name as assignee_name FROM issues i LEFT JOIN users u ON i.assignee_id = u.id WHERE i.sprint_id = @sprintId ORDER BY i.order_index. We also use a DataLoader pattern (similar to Facebook's DataLoader) to batch database lookups for related entities (labels, components, linked issues) into single batch queries per request.
Q5: How would you design the automation rule engine to handle complex nested conditions?
A: We model rules as a tree structure rather than a flat list of conditions. Each node in the tree is either a condition (leaf) or a logical operator (AND/OR/NOT, branch). The evaluation walks the tree recursively. For expression parsing, we support both a visual builder (frontend generates the tree) and a text-based syntax (like status = "Done" AND (priority = "High" OR labels CONTAINS "urgent")). The text parser uses recursive descent parsing to build the tree.
Q6: How do you handle soft-deleted issues in search results?
A: Soft-deleted issues have a deleted_at timestamp. They are excluded from all search queries by default (the Elasticsearch index adds a deleted_at: null filter to every query). Admins can view deleted issues in a "Trash" view with a 30-day recovery window. After 30 days, a background job permanently deletes the issue, its attachments, and its search index entry.
Q7: How do you handle the burndown chart when scope changes mid-sprint?
A: The burndown chart shows two lines: the ideal line (straight from total points to zero) and the actual line (real remaining points). Scope changes (adding/removing issues from the sprint) create "scope change" markers on the chart. The ideal line is recalculated when scope changes: if 10 points are added on day 3 of a 10-day sprint, the ideal line adjusts upward. Some teams prefer the ideal line to remain static — this is configurable.
Pre-Interview Checklist
- Understand issue type hierarchy (Epic → Story → Task → Sub-task) and how sprints relate to each
- Design a workflow engine with custom states, transitions, rules, and actions
- Know how to implement Kanban vs Scrum boards and the difference in data queries
- Understand optimistic concurrency control for real-time board updates
- Design the search architecture (dual-write to PostgreSQL + Elasticsearch)
- Know how to build a JQL-like query parser (Lexer → Parser → AST → Query)
- Understand WebSocket fan-out with Redis Pub/Sub for scaling real-time updates
- Design the webhook delivery system with retries, HMAC signing, and circuit breakers
- Know how to implement burndown, velocity, and cycle time reports from state transition data
- Understand SLA tracking with breach detection and escalation
- Discuss the automation rule engine with trigger-condition-action patterns
- Know the cost estimation for a multi-tenant SaaS at various scales
- Understand RBAC with project-level roles and resource-scoped permissions
- Design the bulk operation system with job queues for large datasets
- Know the import pipeline (Extract → Transform → Load) for migration from Jira/Trello
10. Comments, Activity Feed & File Attachments
Every issue has a conversation history — a chronological feed of comments, status changes, field updates, and system events. This activity feed is the audit trail for the issue's lifecycle.
Activity Event Model
Comment Model with Threaded Replies
File Attachments
Attachments are uploaded to object storage (S3/Azure Blob) via a presigned URL flow. The client requests a presigned upload URL, uploads directly to storage, then confirms the upload with the server. This avoids routing large file uploads through the application server.
pasteevent, extracts the image blob, uploads it to storage, and inserts a markdown reference — all without opening a file picker. This reduces the friction of bug reporting dramatically.