ADR 0023: Company as a first-class entity: schema ownership, identity slug, frozen contract¶
- Status: Accepted
- Date: 2026-07-23
- Deciders: jobhub-architect (David R H)
- Affects: job-service, api-contracts (
job-service.yaml, frozen here), db/init (051), JobHub-ui (consumer change), crawler-service (no change), application-service (no change), notification-service (no change), podman compose mount lists
Context¶
Story #428 (sub-issue #432), parent #426. The product decision is already made and is not reopened here: store a real company entity as the source of truth, do not hardcode company data in the DB, do not do a live third-party lookup on the read path.
Facts verified in the tree, not re-derived:
- Company today is two denormalised columns on
crawler.pull_target:company_name(NOT NULL) andcompany_logo_url(db/init/010-crawler.sql:10-11). job-service reads them through a joinedPullTargetEntityand buildsdomain/model/Companyinadapter/out/persistence/mapper/JobPostMapper(name + logoUrl, nothing else). - The contradiction to resolve. Story #428 says the new table is "owned by job-service
(
jobschema)", but thejobschema is empty:db/initcreates 23 tables, none of them injob. All six job-service entities mapschema = "crawler", and job-service already owns three tables that physically live in the crawler schema (saved_job,saved_filter,trigger_request), with explicit least-privilege grants atdb/init/010-crawler.sql:214-216.quarkus.hibernate-orm.database.default-schema=jobinapplication.propertiesis therefore aspirational today, and the test profile overrides it tocrawleranyway. - The search path already fetches the pull target.
JobPostPanacheRepository:65buildsSELECT j FROM JobPostEntity j LEFT JOIN FETCH j.target t, so company data reached throughpull_targetcosts no extra query and no N+1 (the anti-N+1 work of #333/#406 stays intact). - The company facet and the company filter group by
t.companyName(JobPostPanacheRepository:136-137,:461,:610), andcrawler.saved_filterpersists serialisedFilterValuesthat contain those exact company-name strings. - Every pull target is a single-employer ATS board. The 234 seeded targets are greenhouse (177), workday (29), lever (22), smartrecruiters (4) and amazon (2). There is no aggregator source where one target yields postings from many employers.
CompanyInfois a shared api-contracts type with four consumers (see Blast radius). Story #330 taught that a shared response-type change passes a scopedmvn -plbuild and only a full-reactor build catches the breakage.text[]lesson (#407): an array column whose JPA mapping drifts from prod'stext[]to Hibernate's inferredvarchar[]hides array-operator bugs behind green tests.- Highest existing migration:
db/init/050-auth-apply-profile.sql. - job-service is Hexagonal per
CLAUDE.md. Nothing in this story changes that: the feature is mechanistic (one new stored aggregate, one resolution rule, one read projection), the domain does not grow invariant-heavy behaviour, and there is a near 1:1 mapping between endpoints and use cases. job-service stays Hexagonal.
Decision¶
D1. Physical schema: crawler.company, owned by job-service¶
We will create the table as crawler.company, owned (written, migrated, modelled) by
job-service, exactly like crawler.saved_job, crawler.saved_filter and
crawler.trigger_request already are.
"Owned by job-service" is about who writes it and who models it, not about which schema string
it carries. Putting it in job instead would buy nominal isolation (same database, same
connection, same transaction) and cost a cross-schema foreign key from a crawler-owned table to a
job-owned one, which inverts the ownership direction of the FK and complicates the grant and
drop/recreate lifecycle for no functional gain. Company is a satellite of pull_target and
job_post: it is joined with them in every single search query, so it belongs next to them.
Follow-up recorded, not done here: job remains an empty schema and
quarkus.hibernate-orm.database.default-schema=job remains misleading. That is a pre-existing
inconsistency; fixing it is a repo-wide move (six entities, three tables, all grants) and is not
this story's job.
D2. Link: pull_target.company_id, and job_post reaches company through its existing FK¶
We will add one nullable column, crawler.pull_target.company_id UUID REFERENCES
crawler.company(id). We will not add a column to crawler.job_post.
job_post references company through the FK it already has: job_post.target_id is NOT NULL
and references pull_target(id), and every pull target denotes exactly one employer. The
resolution path is therefore total by construction:
Why the link sits on pull_target rather than on job_post:
- Correctness of the backfill.
company_nameis NOT NULL on every pull target, so settingcompany_idon ~234 rows makes 100% of existing postings resolve a company. The alternative backfills a column on the hottest, largest table in the system. - New postings need no work at all. With the link on
job_post, every newly crawled post arrives withcompany_idNULL (thousands per crawl), which forces either a crawler change or a permanent per-post resolver. With the link onpull_target, a post inherits its target's company the moment it is inserted, and only a brand new pull target (rare, admin or seed driven) ever needs resolving. This is what makes D5 (no crawler change) true. - Zero query cost. The search query already does
LEFT JOIN FETCH j.target t; company is one moreLEFT JOIN FETCH t.companyonto a ~234 row table, on the same statement. No extra query, no N+1, no change to the facet/count caches. - Narrower privilege. job-service needs
UPDATE (company_id)onpull_target(one column on a small config table) instead ofUPDATEoncrawler.job_post, the crawler's core write table.
Forward compatibility, deliberately not built now: if an aggregator source is ever added (one pull
target, many employers), add a nullable job_post.company_id override that wins over the
target's company. Because the domain resolves company inside the mapper, that change touches the
mapper and the repository join only. CompanyInfo does not change.
D3. Identity: the slug rule¶
Company identity is a normalised slug computed from the company name, unique in the table
(uq_company_slug). Two employers arriving from two different ATS sources collapse onto one row
if and only if they produce the same slug.
The rule is a pure function with the raw name as its only input (no CDI, no clock, no
production seam that exists only for tests, per CLAUDE.md). Canonical home:
job-service/.../domain/model/CompanySlug.java, static Optional<String> of(String rawName).
Steps, applied in this exact order (this ordering is load bearing: QA cases assert it):
| # | Step | Example |
|---|---|---|
| S0 | Null or blank after trimming Unicode whitespace (including NBSP U+00A0, tab, newline): no slug, Optional.empty() |
" " to empty |
| S1 | Unicode normalise to NFKC (folds full-width and compatibility forms) | "Stripe" to "Stripe" |
| S2 | Remove every parenthesised or bracketed segment and its content: (...), [...], {...} |
"Block (Square)" to "Block" |
| S3 | Replace & with " and " |
"H&M" to "H and M" |
| S4 | Drop / when it sits between two single letters (slash legal forms) |
"A/S" to "AS" |
| S5 | Drop a trailing internet suffix .com .io .ai .co .net .org when it directly follows a word character (once) |
"Booking.com" to "Booking" |
| S6 | Delete the characters dot, comma, straight quote, curly quote (U+2019), backtick, acute accent (U+00B4) and double quote, with no separator | "S.A." to "SA", "O'Reilly" to "OReilly" |
| S7 | Strip diacritics: NFD decompose, drop combining marks, then map what NFD does not decompose: ø/Ø>o, đ/Đ>d, ð/Ð>d, þ/Þ>th, ł/Ł>l, æ/Æ>ae, œ/Œ>oe, ß>ss, ı>i, ħ>h, ŧ>t, ĸ>k |
"Nestlé" to "Nestle", "Ørsted" to "Orsted" |
| S8 | Lowercase with Locale.ROOT (never the default locale: with a Turkish default "INDITEX" lowercases to "ındıtex" and the slug silently forks) |
"NESTLE" to "nestle" |
| S9 | Every character outside [a-z0-9] becomes a separator; collapse runs to a single -; trim leading/trailing -. Non-ASCII letters that survived S7 (Cyrillic, Greek, CJK) are dropped here |
"Grupo Planeta" to "grupo-planeta" |
| S10 | Split on -. While the last token is in the legal-form set (below) or is a connector (and, und, y, et, e) and at least one token would remain, drop it. Repeat |
"Muller GmbH and Co KG" to "muller" |
| S11 | Truncate to 120 characters at a token boundary (drop whole trailing tokens, never cut mid-token) | |
| S12 | Result must match ^[a-z0-9]+(-[a-z0-9]+)*$. If nothing survives, no slug: no company row is created, and the response falls back to the crawl-time name (log at WARN) |
"楽天" to empty |
Legal-form set for S10 (closed list, lowercase, post S9):
ab, ag, aps, as, asa, bhd, bv, cia, co, company, corp, corporation, cv, doo, gk, gmbh,
inc, incorporated, kft, kg, kk, llc, llp, ltd, ltda, limited, nv, oo, oy, oyj, plc, pt,
pte, pty, sa, sarl, sas, sau, sdn, se, sl, slu, sp, spa, sprl, srl, ug, z, zrt
Deliberately not in the set: group, holding, holdings, international, labs,
technologies, solutions. Stripping those over-merges genuinely different legal entities and
brand lines. Two names differing by such a word are two companies until an admin says otherwise
(story #430).
Worked examples the QA engineer can lift verbatim:
| Raw name | Slug |
|---|---|
Stripe / STRIPE / Stripe, Inc. |
stripe |
Nestlé S.A. / NESTLE SA / Nestle |
nestle |
Block (Square) |
block |
On (On Running) |
on |
Booking.com B.V. |
booking |
Ørsted A/S |
orsted |
H&M / H and M |
h-and-m |
Zalando SE |
zalando |
Grupo Planeta, S.L. |
grupo-planeta |
Müller GmbH & Co. KG |
muller |
Allegro Sp. z o.o. |
allegro |
İŞBANK |
isbank |
Co |
co (S10 guard: never strip to empty) |
Amazon Web Services |
amazon-web-services (deliberately not merged with amazon) |
楽天 |
no slug, no row, falls back to the crawl-time name |
Residual risk, accepted and documented: S10 can merge two genuinely different employers when one
name equals another's name plus a legal suffix (Software AG and a hypothetical Software both
slug to software). The merge is visible in the UI (one card, one name) and is repaired by an
admin rename in story #430. The opposite failure (the same employer forking into two rows) is the
one this story exists to prevent, so the rule is tuned to merge.
Concurrency: resolution is SELECT by slug, then INSERT ... ON CONFLICT (slug) DO NOTHING, then
re-select. Never SELECT then blind INSERT.
D4. The frozen CompanyInfo contract¶
Frozen in this change at api-contracts/src/main/resources/openapi/job-service.yaml. Twelve
properties: id, slug, name, logoUrl, website, industry, size, headquarters,
description, tags, manuallyEdited, updatedAt. name and logoUrl keep
x-implementation-status: existing; every new property is planned.
Rules baked into the spec text:
- Nullability. Every property except
nameisnullable: true. An unknown value serialises asnull, never"", never"-".openApiNullable=falseinapi-contracts/pom.xml, sonullable: trueis pure documentation: the generated model keeps plain Java types and noJsonNullablewrapper appears (verified against the generatedCompanyInfo). tagsis frozen now, empty of behaviour: nothing populates it in #428 (it is filled by #430), and the service never emits[]. Null and "no tags" are the same state, so no consumer needs an empty-array special case. Filtering or faceting by tag stays out of scope.- Projection rule.
GET /jobs(JobPostSummary, the card list) leaves the heavy companydescriptionnull;GET /jobs/{id}(JobPostResponse) populates it. This mirrors story #330, which removed the job's owndescription/requirementsfrom list payloads. Embedding a prose company description in each of 20 cards would undo that work. - Provenance that is externally visible:
manuallyEditedandupdatedAtonly. The DB also stores asourcecolumn, kept internal, because provenance is per field in practice (name from the ATS, logo derived in #429, description admin-edited in #430) and a single scalar would lie. If story #430 needs per-field provenance it adds an admin-only schema, it does not reopenCompanyInfo. industryandsizeare strings, not enums. Values arrive from heterogeneous sources and from admin edits; a frozen closed list breaks every consumer the first time a new value appears (the generated Jackson enum throws on unknown values).sizecarries a documented recommended vocabulary instead:1-10,11-50,51-200,201-500,501-1000,1001-5000,5001-10000,10000+.idandslugare both exposed.idis the immutable handle admin operations (#430) and alerting (#431) address;slugis the stable grouping key the UI should use instead of deriving one client side. Both are null while a posting's company is unresolved (the fallback window described in D5), which is exactly when the response carries onlynameandlogoUrl.
D5. Resolution path: job-service reconciler, plus fallback. crawler-service is NOT changed.¶
Explicit call: NO. crawler-service needs no change in this story. Reasons:
- A newly crawled
job_postinherits its target's company throughtarget_id(D2), so a new posting resolves a company the instant it is inserted, with no crawler code involved. - Company data is not produced per posting by any current source client: all five source types are single-employer boards and the employer is already recorded on the pull target.
- The only gap is a brand new pull target with
company_idstill NULL. That is rare (seed or admin driven), and it is covered twice over: by the reconciler below, and, until the reconciler next runs, by the mapper fallback that reads the denormalisedpull_target.company_name/company_logo_urlexactly as today. No posting ever loses its company name, at any moment.
Resolution therefore lives in job-service as a small scheduled reconciler
(adapter/in/scheduler/, the hexagonal home for @Scheduled): find pull targets with
company_id IS NULL, slug the name, upsert the company, assign the id. Batched, idempotent,
config-guarded, off the read path (no writes during a GET).
Crawler work becomes necessary only if an aggregator source is added, which is the same trigger as
the job_post.company_id override in D2. If the user wants richer company data captured at crawl
time (ATS payloads sometimes carry a website or a description), that is story #429/#430 territory
and deserves its own ticket, not a widening of this one.
D6. Migration: db/init/051-job-company.sql¶
Assigned number: 051. Exactly one new file for this story. It must contain, in order:
CREATE TABLE crawler.company:id UUID PK DEFAULT gen_random_uuid(),slug TEXT NOT NULL,name TEXT NOT NULL,website TEXT,industry TEXT,size TEXT,headquarters TEXT,description TEXT,logo_url TEXT,tags TEXT[],source VARCHAR(16) NOT NULL DEFAULT 'crawl',manually_edited BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(). Constraints:uq_company_slug UNIQUE (slug),chk_company_source CHECK (source IN ('crawl','derived','manual')),chk_company_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$').tagsisTEXT[]: the #407 lesson applies the day anything filters on it (an array-overlap filter needs an explicittext[]cast, and the JPA mapping must pincolumnDefinition = "text[]"so the drop-and-create test schema cannot drift tovarchar[]).- No
updated_attrigger. job-service is the only writer and setsupdated_aton write, so the DevServices drop-and-create test schema behaves identically to prod. This deliberately differs fromcrawler.pull_target, where the trigger exists because job-service is not the writer. ALTER TABLE crawler.pull_target ADD COLUMN company_id UUID REFERENCES crawler.company(id). No new index:pull_targetis ~234 rows and the join usescompany's primary key.- Backfill, in one transaction: insert one
crawler.companyrow per distinct slug computed frompull_target.company_name(carryingnamefrom a deterministic pick, for example the longest or first name in the group, andlogo_urlfrom the first non-nullcompany_logo_urlin the group,source = 'crawl'), thenUPDATE crawler.pull_target SET company_id = ...for every row. Every pull target must end with a non-nullcompany_id; end the file with a verificationSELECTof the two counts so the devops check on #437 is mechanical. The SQL slug expression mirrors D3 for the data that exists (all current names are Latin;unaccentcovers S7). The Java rule stays canonical: the reconciler only ever touches targets whosecompany_idis NULL, so a divergence between the SQL mirror and the Java rule can at worst create a duplicate row for a future target, never a lost or renamed company. - Grants (this is the entire least-privilege impact):
GRANT SELECT, INSERT, UPDATE ON crawler.company TO job_user;andGRANT UPDATE (company_id) ON crawler.pull_target TO job_user;. No DELETE anywhere (deleting a company would orphan the link; merges are #430 territory). No change todb/init-users.sh: no new role, no new schema, no new password, nothing for.envor the compose files beyond mounting the file.crawler_userpicks up DML oncrawler.companyautomatically from theALTER DEFAULT PRIVILEGESin001-schemas.sql; that is unused but harmless and already true ofcrawler.saved_job. Hand-apply the migration as the same superuser (podman exec -i jobhub-db psql -U jobhub -d jobhub < db/init/051-job-company.sql) so the default privileges match a fresh volume. - Mount the file by hand in both
podman-compose.ymlandpodman-compose.native.yml(line 115 area) and in theCLAUDE.mdmount list. They drift; this was missed on #336. That is sub-issue #437.
Where the pieces land (job-service, Hexagonal)¶
domain/model/Company.java extend: id, slug, name, website, industry, size,
headquarters, description, logoUrl, tags,
manuallyEdited, updatedAt (@Getter @Builder, immutable,
zero framework annotations)
domain/model/CompanySlug.java NEW, pure: static Optional<String> of(String rawName)
domain/port/out/CompanyRepository.java NEW: Optional<Company> findBySlug(String),
Company upsertBySlug(Company)
domain/port/out/PullTargetRepository NEW: List<UnresolvedTarget> findWithoutCompany(int limit),
void assignCompany(UUID targetId, UUID companyId)
domain/port/in/ResolveCompaniesUseCase NEW: int resolvePending()
domain/service/CompanyResolutionService implements the use case, injects ports only,
@Transactional per batch
adapter/in/scheduler/CompanyResolutionScheduler NEW, @Scheduled, config-guarded
adapter/out/persistence/entity/CompanyEntity NEW, @Table(name="company", schema="crawler"),
explicit @Column(name="snake_case"),
tags: columnDefinition="text[]" + @JdbcTypeCode(ARRAY)
adapter/out/persistence/entity/PullTargetEntity add @ManyToOne(LAZY) company + company_id
adapter/out/persistence/mapper/CompanyMapper NEW, entity to domain
adapter/out/persistence/CompanyPanacheRepository NEW, implements the port + PanacheRepositoryBase
adapter/out/persistence/JobPostPanacheRepository search JPQL gains LEFT JOIN FETCH t.company
adapter/out/persistence/mapper/JobPostMapper company from target.company, else fall back to
target.companyName/companyLogoUrl (name+logo only)
adapter/in/rest/dto/JobPostResponseMapper map all 12 properties, blank strings to null
adapter/in/rest/dto/JobPostSummaryMapper same minus description (projection rule)
application.properties job.company.resolve.enabled=true
job.company.resolve.every=15m
job.company.resolve.batch-size=200
Tests follow CLAUDE.md: unit under unit_tests/ (CompanySlugTest from the D3 table,
CompanyMapperTest, JobPostMapperTest fallback, both DTO mappers for null-not-empty-string and
the projection rule), component under component_tests/ with two seeded pull targets for one
employer across two source types. Hibernate drop-and-create builds crawler.company from the
entity in tests, so only src/test/resources/db/test-seeds.sql needs new rows. No WireMock:
job-service has no outbound HTTP for this feature.
Blast radius of the shared contract change¶
Verified by a full-reactor mvn -DskipTests test-compile on this branch (green), not by a
scoped -pl build. This is the #330 check.
| Consumer | Uses | Impact |
|---|---|---|
| api-contracts | generates job-api models |
Additive only: CompanyInfo gains 10 properties, no existing property changes name or type (name: String, logoUrl: URI untouched). slug carries @Pattern/@Size, which are inert on a response model (returnResponse=true, no @Valid on resource returns). |
| job-service | produces CompanyInfo |
The real work (#435). JobPostResponseMapperTest/JobPostSummaryMapperTest and JobResourceComponentTest need new assertions. Existing TC-1 uses hasItems on the key set, not exact equality, so additive fields do not break it. Note that TC-2 only guards top level description, so nothing today would catch a company description leaking into the card list: that needs its own case. |
| application-service | hand-written JobPostRemoteResponse.CompanyInfo(String name, String logoUrl) record, not the generated model |
Unaffected at compile time and at runtime: both the outer record and the nested one carry @JsonIgnoreProperties(ignoreUnknown = true). The apply-time logo snapshot (db/init/031) is unchanged. Only effect: a slightly larger GET /jobs/{id} payload. |
| notification-service | generated JobPostSummary + CompanyInfo in JobSearchGatewayAdapter (getCompany().getName(), getCompany().getLogoUrl()) |
Source compatible: additive properties do not touch those getters, and logoUrl stays URI (its DigestJob.companyLogoUrl is a URI). WireMock stubs that omit the new fields deserialise them as null. Verified: unit + main sources compile. |
| auth-service / crawler-service | depend on api-contracts but not on the job models | No impact (no job.contract import in either). |
| JobHub-ui | src/api/mappers.js |
The behaviour change users see (#436). Three specific traps: (a) jobFromApi synthesises industry: "-", size: "-", hq: location and must stop; (b) it registers a company only if (!companies[co.key]), so a rich GET /jobs/{id} company arriving after a list entry is dropped: it must upgrade the entry, never downgrade it, which is exactly the projection rule in D4; (c) its local slug() lowercases, strips non-alphanumerics and truncates at 16 characters, so it is not the backend slug and will collide differently: use dto.company.slug when present and keep the local derivation only as the fallback. Also note the job-level tags: [] in jobFromApi is a different field from company.tags. |
| db / running stack | db/init + both compose files |
New file must be mounted in both compose files and hand-applied on existing volumes (#437). job-service runs Hibernate validate in prod, so the migration must land before the new entity mapping does. |
Consequences¶
- Positive. Every acceptance criterion of #428 is satisfied structurally rather than by
best effort: the link is total (NOT NULL
target_id), so "every existing job post still resolves a company name" cannot regress; two sources for one employer collapse by slug; unknown fields are null by contract; the fallback makes the pre-resolution window invisible to users. - Positive. No new query on the hot search path, no N+1, no change to the count/facet caches,
no new DB role, no
.envchange, and no crawler change. - Cost. job-service now writes into two more places in the crawler schema
(
crawler.company, and one column ofcrawler.pull_target). The ownership split inside the crawler schema, already true for three tables, grows to five objects and has to stay documented. - Cost. The slug rule exists twice for one migration: canonical in Java, mirrored in SQL for the
historical backfill. Contained by "the reconciler only touches NULL
company_id", but it is real and must be called out in the migration's header comment. - Cost.
CompanyInfonow carries fields that nothing populates yet (tags, and in #428 also most of the rest). That is the point of a freeze, but it means the UI must be written to render sparse data from day one. - Follow-ups, explicitly out of scope here.
- The company facet and the company filter still group by
t.companyName(JobPostPanacheRepository:136,:461,:610), so "Stripe" and "Stripe, Inc." remain two buckets. Regrouping oncompany.slugwould change the facet values thatcrawler.saved_filterhas already persisted inside serialisedFilterValues, breaking users' saved presets. That needs its own story with a migration for saved filters. - Logo sourcing (#429), admin editing and company merge (#430), sparse-company alerting (#431), tag filtering/faceting.
- The empty
jobschema and job-service's misleadingdefault-schema=job.
Alternatives considered¶
job.companywith a cross-schema FK fromcrawler.pull_target. Rejected: it inverts FK ownership (a crawler-owned table pointing at a job-owned one), complicates grants and the drop/recreate lifecycle, and buys no isolation that matters inside one database and one connection. The story's "job schema" wording describes ownership, which D1 preserves.job_post.company_idas the link. Rejected: it forces a backfill of the largest table, anUPDATEgrant to job-service on the crawler's core write table, and, worst, leaves every newly crawled posting NULL, which would make a crawler change or a permanent per-post resolver mandatory. Kept as the documented forward path if an aggregator source ever appears.- Resolve the company in crawler-service at crawl time. Rejected for this story: it widens the blast radius to a second service and a second schema owner for zero user-visible gain, since the target-level link already covers every current source. Revisit when a source starts returning per-posting employer data.
- A separate slim
CompanySummaryschema forJobPostSummary. Deferred: it would type-enforce the projection rule, but it doubles the frozen surface for one field. The nullable + documented projection rule achieves the same payload shape; introduce the second type only if a consumer needs to distinguish "not loaded" from "not set" for companydescription. - Enums for
industry,size, and an exposedsource. Rejected: a frozen closed list on a response type breaks consumers on the first unknown value, and a single provenance scalar misrepresents what is genuinely per-field provenance. - Hardcoded company data, or a live third-party lookup on the read path. Already rejected by story #428; recorded here so the ADR stands alone.