Skip to content

5. Hexagonal Architecture — Ports & Adapters

Every port the domain depends on and the local adapters that implement them. Part of the Domain Model reference.

A port is an interface the domain uses to reach the outside world; an adapter is a concrete implementation of that port. Driving (inbound) ports are the use cases external callers invoke; driven (outbound) ports are the infrastructure the domain calls out to. Each context below lists both, with the local adapter in use today and the hosted adapter named for the cloud future.

Read this if you need to know where an external dependency — a database, an LLM, a browser, the filesystem — plugs into the domain, and what would change to swap it for a hosted service.

Port Naming Convention

  • Driving ports (inbound): named as use cases — ScoreJobUseCase, TailorResumeUseCase.
  • Driven ports (outbound): named as capabilities — JobRepository, LlmPort, BrowserPort.

Realisation status. The driving-port use-case names below are the conceptual command surface; the implemented application layer under application/ currently exposes about eighteen use cases, and some names differ from the idealised ones listed here. A few driven ports in the tables below are documented as conceptual seams and are not realised as distinct named types in the current code: ProfileSnapshotPort (the code passes ProfileSnapshot value objects; see domain/profile/snapshot.py), ArtifactStoragePort (artifacts are written directly to the local filesystem and registered in the read model), EventSubscriber (the in-process InProcessEventBus is subscribed directly), and ReadModelStore (the projection store in infrastructure/projections/ is the concrete read side). They are kept here because each names a real hosted-future seam.

5.1 Job Discovery Context

Port TypePortDescription
DrivingDiscoverJobsUseCaseTrigger discovery for configured search strategies
DrivingImportJobUseCaseManually add a single job by URL
DrivingDeleteJobUseCaseSoft-delete a job (tombstone record). Emits JobDeleted.
DrivingRestoreJobUseCaseRestore a soft-deleted job. Emits JobRestored.
DrivenJobBoardScraperPortScrape job postings from external boards
DrivenJobRepositoryPersist and retrieve Job aggregates
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
JobBoardScraperPortJobSpyAdapter, WorkdayApiAdapter, SmartExtractAdapter (Playwright + LLM)Same adapters, deployed as Kubernetes Jobs behind AWS SQS task queue with per-tenant rate limiting
JobRepositorySqliteJobRepositoryPostgresJobRepository (AWS RDS Postgres 16 with pgbouncer connection pooling, tenant-scoped via tenant_id column + RLS)
EventPublisherInProcessEventBus (synchronous)SqsEventPublisher (AWS SQS FIFO queues, one per bounded context, with message group = tenantId for ordered per-tenant delivery)

Seam justification: Job board scraping is the most brittle integration point (anti-scraping measures, API changes, rate limits). The port lets us swap scrapers without touching domain logic. The repository port lets us switch from SQLite to Postgres without changing Discovery logic.

5.2 Job Enrichment Context

Port TypePortDescription
DrivingEnrichJobUseCaseEnrich a specific job's detail page
DrivingEnrichBatchUseCaseEnrich a batch of jobs
DrivenDetailPageFetcherPortNavigate to and extract content from job detail pages
DrivenLlmPortLLM-assisted extraction (Tier 3)
DrivenEnrichmentRepositoryPersist enrichment results
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
DetailPageFetcherPortPlaywrightBrowserAdapter (local Playwright instance)BrowserbaseAdapter (Browserbase managed browser fleet; sessions allocated per-tenant with concurrency cap; fallback: headless Chromium in Kubernetes pods with Playwright)
LlmPortA single LlmAdapter (infrastructure/llm/llm_client.py) that selects the provider from the model-spec prefix (gemini: / openai: / local:) and wraps the legacy LLMClient — there is no per-provider adapter classCloudLlmGatewayAdapter (internal gateway service fronting Anthropic Claude / Google Gemini / OpenAI APIs with per-tenant token metering, rate limiting, and cost attribution via Billing context)
EnrichmentRepositorySqliteEnrichmentRepositoryPostgresEnrichmentRepository (RDS Postgres, tenant-scoped)

Seam justification: Enrichment's Playwright dependency is the primary obstacle to horizontal scaling. The DetailPageFetcherPort lets us swap local Playwright for a hosted browser fleet without changing extraction logic.

5.3 Candidate Profile Context

Port TypePortDescription
DrivingGetProfileUseCaseRetrieve the current profile snapshot
DrivingUpdateProfileUseCaseUpdate profile sections
DrivingImportProfileUseCaseImport profile from a resume PDF
DrivenProfileRepositoryPersist and retrieve the Profile aggregate
DrivenPdfParserPortExtract text and structure from resume PDFs
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
ProfileRepositorySqliteProfileRepository (normalized candidate_profile* tables; explicit saves and resume imports populate profile/rendering rows)PostgresProfileRepository (RDS Postgres, keyed by (tenant_id, profile_id))
PdfParserPortPyPdfAdapterPyPdfAdapter (same library, runs in worker pod; no cloud service needed)

Seam justification: Profile storage is isolated behind the repository port: local SQLite and hosted Postgres adapters expose the same aggregate contract.

5.4 Scoring Context

Port TypePortDescription
DrivingScoreJobUseCaseScore one job against the candidate profile
DrivingScoreBatchUseCaseScore a batch of jobs
DrivingCorrectScoreUseCaseUser overrides a score
DrivenLlmPortLLM scoring (prompt + response parsing)
DrivenScoreRepositoryPersist JobScore aggregates
DrivenProfileSnapshotPortRead-only access to the current profile
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
LlmPortA single LlmAdapter, prefix-dispatched by model spec (see Enrichment)CloudLlmGatewayAdapter (see Enrichment; shared gateway service)
ScoreRepositorySqliteScoreRepositoryPostgresScoreRepository (RDS Postgres, tenant-scoped)
ProfileSnapshotPortLocalProfileSnapshotAdapter (reads the SQLite-backed Profile repository)ProfileServiceGrpcClient (internal gRPC call to Profile service; tenant context propagated via gRPC metadata)

5.5 Materials Generation Context

Port TypePortDescription
DrivingTailorResumeUseCaseGenerate a tailored resume for one job
DrivingGenerateCoverLetterUseCaseGenerate a cover letter for one job
DrivingRenderPdfUseCaseRender documents to PDF
DrivingTailorBatchUseCaseBatch tailor + cover + PDF for multiple jobs
DrivenLlmPortLLM for tailoring and cover letter generation
DrivenAnalysisDraftPort / AnalysisSynthesizerPortEmployer/company analysis via the 3-SDK agent ensemble (second LLM path)
DrivenPdfRendererPortRender HTML to PDF
DrivenArtifactStoragePortWrite and register generated files
DrivenMaterialsRepositoryPersist MaterialsSet aggregates
DrivenProfileSnapshotPortRead-only access to the current profile
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
LlmPortA single LlmAdapter, prefix-dispatched by model spec (see Enrichment)CloudLlmGatewayAdapter (shared gateway; see Enrichment)
AnalysisDraftPort / AnalysisSynthesizerPortThe 3-SDK agent ensemble (infrastructure/analysis/): ClaudeAnalysisAdapter, CodexAnalysisAdapter, and AntigravityAnalysisAdapter draft employer/company analysis in parallel; ClaudeAnalysisSynthesizer merges them via run_ensemble. This is a second LLM path, distinct from the prefix-dispatched LlmPort aboveSame ensemble fronted by the cloud LLM gateway, with per-tenant token metering
PdfRendererPortHtmlResumePdfAdapter (structured resume HTML/CSS + Playwright renderer with layout boxes), PlaywrightHtmlPdfAdapter (cover letters)HTML/CSS + Playwright/Chromium resume rendering. Cover letters: WeasyPrintAdapter (pure-Python HTML→PDF, no browser needed in cloud)
ArtifactStoragePortLocalFilesystemAdapter (writes to ~/.jobctrl/tailored_resumes/, etc.)S3ArtifactAdapter (AWS S3 with tenant-prefixed keys: s3://jobctrl-artifacts/{tenantId}/{jobId}/; presigned URLs for browser download; lifecycle policy for cost control)
MaterialsRepositorySqliteMaterialsRepositoryPostgresMaterialsRepository (RDS Postgres, tenant-scoped)

Seam justification: The PdfRendererPort absorbed the renderer swap: HTML/CSS + Playwright is the production default for resume PDFs, emits layout boxes for Apply Review, and historical latex_pdf rows remain metadata for migration/inspection without a render adapter. The ArtifactStoragePort absorbs the local-to-cloud transition for generated files.

5.6 Apply Automation Context

Port TypePortDescription
DrivingSubmitApplicationUseCaseLaunch an apply run for one job
DrivingSubmitBatchUseCaseBatch apply for multiple jobs
DrivenBrowserPortManage Chrome lifecycle (launch, CDP, cleanup)
DrivenAutonomousAgentPortSpawn and manage Claude Code subprocess with MCP
DrivenApplyRunRepositoryPersist ApplyRun aggregates
DrivenArtifactStoragePortRead materials (resume PDF, cover letter PDF)
DrivenProfileSnapshotPortRead candidate application defaults
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
BrowserPortLocalChromeAdapter (launch Chrome on isolated CDP ports)BrowserbaseAdapter (Browserbase managed sessions; per-tenant session pool with concurrency limits enforced by Billing entitlements; alternative: headless Chromium sidecars in Kubernetes pods with CDP exposed via localhost)
AutonomousAgentPortClaudeCodeCliAdapter (subprocess + Playwright MCP)ClaudeApiAgentAdapter (Anthropic Claude API with tool use / computer use; direct Playwright API calls from a managed agent container; apply prompt unchanged, transport switches from CLI subprocess to API request)
ApplyRunRepositorySqliteApplyRunRepositoryPostgresApplyRunRepository (RDS Postgres, tenant-scoped)

Seam justification: Apply Automation is the most infrastructure-heavy context. The BrowserPort and AutonomousAgentPort isolate the two most complex infrastructure dependencies (Chrome lifecycle and Claude Code subprocess) behind clean interfaces. This is essential for the hosted future where browsers and agents are managed fleet resources.

5.7 Pipeline Orchestration Context

Port TypePortDescription
DrivingRunPipelineUseCaseExecute pipeline stages for a batch of jobs
DrivingRetryStageUseCaseReset and retry a specific stage for a job
DrivingCancelStageUseCaseCancel a running stage
DrivingMarkAppliedUseCaseManually mark a job as applied (no apply run). Transitions apply stage to Succeeded.
DrivingSkipJobUseCaseSkip a job at relevant stages (e.g., below score threshold, not interested). Transitions specified stages to Skipped.
DrivenPipelineStateRepositoryPersist JobPipelineState aggregates
DrivenEventPublisherPublish domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
PipelineStateRepositorySqlitePipelineStateRepositoryPostgresPipelineStateRepository (RDS Postgres, tenant-scoped)

Stage dispatch is Temporal, not an in-process seam. Earlier drafts routed stage commands through a StageCommandDispatcher port with an InProcessDispatcher local adapter and a Temporal hosted adapter. That seam no longer exists: Temporal is the local orchestrator. A pipeline run is a JobPipelineWorkflow (starter-generated random id run-{uuid4}, no overlap/dedup control); per-job preparation is a JobPreparationWorkflow (prep-{idempotency_key}); each stage is a Temporal activity with retry, timeout, heartbeat, and finalize semantics. For the concrete execution model see the System Architecture overview and the Job Pipeline section.

5.8 Operations / Read-Side Context

Port TypePortDescription
DrivingGetDashboardUseCaseDashboard summary query
DrivingListJobsUseCasePaginated, filtered job list
DrivingGetJobDetailUseCaseFull job detail with stages and artifacts
DrivingListArtifactsUseCaseArtifact listing with provenance
DrivingGetApplyRunsUseCaseApply run telemetry queries
DrivenReadModelStoreQuery read-model projections
DrivenEventSubscriberSubscribe to domain events for projection updates
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
ReadModelStoreSqliteReadModelStore (same DB, denormalized views)PostgresReadModelStore (AWS RDS Postgres read replica; tenant-scoped queries via RLS; optional ElastiCache Redis for dashboard aggregation caching)
EventSubscriberInProcessEventBusSqsEventConsumer (AWS SQS FIFO consumer; reads from per-context queues; processes events in tenant-ordered batches; dead-letter queue for failed projections)

5.9 Contact & Outreach Context

Phase 1 realises the Contact aggregate's ports; Phase 2 adds the supervised-research ports; Phase 3 adds the OutreachThread aggregate's ports (draft generation/revision and the approve/reject transitions).

Port TypePortDescription
DrivingCreateContactUseCaseCreate a contact linked to an employer and/or application, with user-entered provenance
DrivingUpdateContactUseCaseUpdate a contact's link, role, or attributes
DrivingImportContactsUseCaseImport a user-provided CSV contact list (CSV only), tagging every fact user_imported_list
DrivingDeleteContactUseCaseSoft-delete a contact
DrivingRunContactResearchUseCaseStart a supervised research run: authorise sources (INV-3), fetch allowed public pages through the gateway, propose candidates in needs_review (INV-4)
DrivingConfirmContactCandidateUseCasePromote a needs_review candidate into a stored Contact fact, preserving provenance (INV-2)
DrivingGenerateOutreachDraftUseCaseGenerate a fresh LLM-authored draft generation and run the full truthfulness gate stack (INV-5); persists a gated candidate draft
DrivingReviseOutreachDraftUseCaseAccept a user-edited body as a new generation and RE-RUN the identical gates; the prior approved draft stays readable until this revision is approved
DrivingApproveOutreachDraftUseCaseApprove a candidate draft — only when its persisted DraftGateResults passed (INV-5); supersedes the previously-approved draft
DrivingRejectOutreachDraftUseCaseReject a candidate draft; never destroys the last approved draft (INV-5)
DrivingLogOutreachSendUseCaseRecord a user-attested send of an approved draft (OutreachSendLog) — the only path to a "sent" thread; refuses a non-approved draft; no transport (INV-1)
DrivingScheduleFollowUpUseCaseSchedule the next follow-up; derives a suggested date (7d after submission, 14d nudge) when none is supplied — surfaced-only, user-editable, never sent (§9)
DrivingCompleteFollowUpUseCase / DismissFollowUpUseCaseMark a scheduled follow-up done or dismissed — explicit user actions
DrivenContactRepositoryPersist and retrieve the Contact aggregate (tenant-scoped)
DrivenContactResearchTaskRepositoryPersist and retrieve the ContactResearchTask aggregate (task + candidates + source attempts)
DrivenOutreachThreadRepositoryPersist and retrieve the OutreachThread aggregate (thread + generation-versioned drafts, gate results, claim provenance, user-attested send logs, and the follow-up schedule), tenant-scoped
DrivenResearchPageFetcherPortGateway-routed public-page fetch; the single research outbound choke point (robots/rate-limit/budget are first-class outcomes)
DrivenLlmPortSchema-driven candidate extraction from a fetched page; outreach draft synthesis and the LLM-as-judge gate
DrivenEventPublisherPublish contact, research, and outreach-draft domain events
Driven PortLocal Adapter (today)Hosted Adapter (cloud)
ContactRepositorySqliteContactRepository (publisher-injected; writes contacts + contact_attributes, then records contact events to job_events)PostgresContactRepository (RDS Postgres, tenant-scoped)
ContactResearchTaskRepositorySqliteContactResearchTaskRepository (publisher-injected; writes contact_research_tasks + contact_candidates, records research events to job_events)PostgresContactResearchTaskRepository (tenant-scoped)
ResearchPageFetcherPortGatewayContactResearchFetcher (wraps PolitenessSession.guard + urllib)Hosted fetch behind the same gateway contract
OutreachThreadRepositorySqliteOutreachThreadRepository (publisher-injected; writes outreach_threads + outreach_drafts, then records draft-lifecycle events to job_events)PostgresOutreachThreadRepository (RDS Postgres, tenant-scoped)

Hosting. Phase 1's contact state-transition commands (create / update / CSV import / soft-delete) are simple, LLM-free, and browser-free, so they are hosted directly in the TypeScript API (apps/api/src/contacts.ts) per §6.8; the Python worker exposes the same use cases and SqliteContactRepository, writing the same canonical tables and event types. Phase 2's research run (LLM + fetch) executes on the Python worker via Temporal (ContactResearchWorkflow), started through the run_contact_research JSON-RPC method; candidate confirmation is a simple state transition hosted directly in the TypeScript API (apps/api/src/contact-research.ts) per §6.8. Phase 3's draft generation and revision (LLM + the reused materials truthfulness gate stack) execute on the Python worker via the synchronous generate_outreach_draft JSON-RPC method (editedBodyText selects the revise path); draft approval and rejection are simple lifecycle transitions hosted directly in the TypeScript API (apps/api/src/outreach.ts) and HARD-gated on the persisted gate_results_json.passed (INV-5) per §6.8.

Seam justification: the repository port isolates contact storage exactly like every other aggregate — local SQLite today, hosted Postgres tomorrow — with no change to the contact domain logic.


Documentation screenshots and examples use synthetic data unless noted.