Hexagonal architecture: your workflow should not know it runs on Jira
Ports and adapters keep business rules apart from the tools around them, so a database, tracker, or app can change without rewriting the workflow.
- TypeScript
- Node.js
- Jira
- PostgreSQL
- Python
- React Native
A leave-approval workflow is the same workflow whether the requests live in Jira or in a database, and whether people use a web page or a phone. If the code does not reflect that, every tool change becomes a rewrite. Hexagonal architecture (also called ports and adapters) is how we keep the rules apart from the tools.
The shape
In the middle is the core: plain TypeScript with the business rules and use cases (“submit a request”, “approve it”, “escalate after three days”). It imports no framework, no HTTP library, no database driver. Around it, the core declares ports: small interfaces for what it needs from the world. Outside, adapters implement them.
- Driving adapters start work: the REST API on Node.js, a React Native app through that API, Jira webhooks, scheduled jobs.
- Driven adapters do work the core asks for: storing cases in PostgreSQL (or MySQL when it is your standard), creating issues in Jira, extracting a document with a Python service.
A port is an interface the core owns
The core says what it needs in its own words. It does not say “Jira”; it says “a task tracker that can open a task and tell me when it is done”.
export interface TaskTracker {
openTask(input: { caseId: string; title: string; assignee: string }): Promise<{ taskId: string }>;
comment(taskId: string, text: string): Promise<void>;
}
export interface CaseRepository {
get(id: string): Promise<Case | null>;
save(c: Case, audit: AuditEntry): Promise<void>; // one transaction
}export async function approveRequest(
deps: { cases: CaseRepository; tracker: TaskTracker; clock: Clock },
cmd: { caseId: string; approver: User },
) {
const c = await deps.cases.get(cmd.caseId);
if (!c) throw new NotFound("case");
if (!c.canBeApprovedBy(cmd.approver)) throw new Forbidden("approve");
const approved = c.approve(cmd.approver, deps.clock.now());
await deps.cases.save(approved, audit("approved", cmd.approver, approved));
await deps.tracker.comment(approved.taskId, `Approved by ${cmd.approver.name}`);
return approved;
}Adapters are where the tools live
The Jira adapter knows Jira’s REST API, its field IDs, and its rate limits. Nothing else does. Swap it for another tracker and the core does not change; the tests do not change either.
export class JiraTaskTracker implements TaskTracker {
constructor(private http: JiraHttp, private project: string) {}
async openTask({ caseId, title, assignee }) {
const issue = await this.http.post("/rest/api/3/issue", {
fields: {
project: { key: this.project },
summary: title,
assignee: { id: assignee },
labels: [`case-${caseId}`],
},
});
return { taskId: issue.key };
}
async comment(taskId: string, text: string) {
await this.http.post(`/rest/api/3/issue/${taskId}/comment`, { body: adf(text) });
}
}Why it pays off
- Tests without the world. The core is tested with in-memory adapters in milliseconds; adapters get their own contract tests against a real PostgreSQL or a Jira sandbox.
- Implementing what you already have. When a client already runs a system, we write adapters around it instead of replacing it. See implement or build.
- Room to grow. A second front end (a React Native app next to the web dashboard) is another driving adapter on the same core and the same rules.
- Honest boundaries. When an integration misbehaves, the problem sits in one adapter, with its own logs and retries.
Want this in your systems?
Tell us about the workflow; we reply with questions or a proposal for a discovery call.