AUTOMOTIVE · Marketplace platform, end to end
A marketplace for caravans and motorhomes in five languages, architected first and built in three days.
A Romanian classifieds marketplace for caravans, motorhomes and campervans: private sellers publish and promote listings, buyers search by distance, budget and free text in five languages, and a staff console runs moderation, bank-transfer payments, invoices and six reports. Coderlytics wrote the architecture before the first line of code, then built the whole platform — an ASP.NET Core API on .NET 10 with PostgreSQL and PostGIS, and a server-rendered Next.js front end, all on Azure — in 107 pull requests over three days.
At a glance
| Product | A classifieds marketplace for buying and selling caravans, motorhomes and campervans in Romania, working name roamio |
| Who uses it | Buyers, who search and contact sellers without an account; private sellers, who publish and promote listings; and the staff who moderate listings, confirm payments and run the business |
| Engagement | Coderlytics wrote the architecture and built the platform end to end: back end, front end, cloud infrastructure and delivery pipeline. The product owner made the product and commercial decisions |
| Timeline | Built from 20 to 23 September 2026: 107 pull requests in three days |
| Status | Running on Azure in a pre-launch environment. Production waits on the final brand name and domain |
| Back end | ASP.NET Core Web API on .NET 10, C#, Entity Framework Core, PostgreSQL 17 with PostGIS, Hangfire |
| Front end | Next.js 16 with the App Router, React 19, TypeScript and Tailwind CSS, server-rendered and in five languages |
| Cloud | Microsoft Azure: Static Web Apps, App Service, PostgreSQL Flexible Server, Blob Storage, Key Vault, Communication Services and Application Insights, defined in Bicep and deployed from GitHub Actions over OIDC |
The problem
A classifieds marketplace looks simple from the outside: people post vehicles, other people find them. Underneath, it is three products that have to agree with each other.
Buyers need search that works the way they think — a type of vehicle, a budget, somewhere they can drive to — and they need it without creating an account. Private sellers need to publish a listing with up to thirty photographs and then leave it alone. And the business needs a back office: someone has to check every listing before it goes live, confirm payments for paid packages, issue invoices an accountant will accept, and see what is going on.
This one came with constraints that would each have been enough to slow a project down:
- Five languages at launch — Romanian, English, German, French and Spanish — with the addresses in the browser translated too.
- Two currencies. Vehicles are priced in euro or lei, packages are paid in either, and invoices have to be right in both.
- No payment provider yet. Customers pay by bank transfer, and staff confirm the money has arrived.
- Everything before launch. One release with no cut-down first version: search, publishing, messaging, packages, invoices, reports, a content system and the admin console all had to be there on day one.
- Azure, on the hosting the owner chose — including an API that keeps Azure's default address, which quietly breaks the usual way of keeping users signed in.
The brief: a marketplace that is complete on day one, in five languages and two currencies, built so that dealers, online payments and a mobile app can be added later rather than retrofitted.
What the platform does
Three audiences, and one listing at the centre of all of them.
One listing, from a seller's draft to a sale. Select the diagram to open it full size.
For buyers: the right vehicle, close enough to go and see
The home page in English. Screens in this study use sample listings and the photographs from the product's own design file. Select any screenshot to open it full size.
- Search that matches how people shop. Vehicle type, a budget in euro or lei, a county or a town with a radius of up to 100 km, year, number of berths and free text. Results sort by newest, price or year, and a radius always beats the town it is centred on — "Cluj + 50 km" means fifty kilometres around Cluj, not Cluj.
- No account needed to make contact. The phone number sits behind a button, is rate-limited and logged, and never appears in the page, its structured data or the sitemap. Messaging keeps one conversation per listing and buyer, and a seller cannot message anyone first.
- Tools for a considered purchase. Compare up to three vehicles side by side, keep favourites, and save up to twenty searches with a daily or weekly e-mail digest and a one-click unsubscribe.
- Pages people actually search for. Category and town pages such as "motorhomes for sale in Cluj" are created only where there is enough stock to justify one, so the site does not fill search engines with thin pages. Buyer's guides are written in a built-in content system.
Search results with the filter rail. Promoted listings come first within the chosen sort.
For sellers: one page to publish, and photographs that never pass through the API
- One page, three sections. Vehicle details; photographs and description; contact details. A draft can be saved with nothing but the vehicle type, and completeness is checked only on submit. Models are suggested from the chosen make, but anything can be typed.
- Up to thirty photographs, straight to storage. The browser uploads each file directly to Azure Blob Storage. A background job turns it into three WebP sizes and strips its metadata, including GPS coordinates, before it is shown. Photographs are reordered with buttons rather than drag and drop, so it works with a keyboard, a screen reader and a thumb.
- A clear lifecycle. A moderator checks every listing before it goes live, and editing a moderated field — the title, price, category, description or photographs — sends it back for review. Listings run for sixty days, with a reminder three days before and a one-click renewal. A sold listing shows a "sold" banner for thirty days and is then gone for good.
- A dashboard and a paid tier. Active listings, drafts, views over thirty days and unread messages at a glance. Packages add more active listings, monthly promotions and detailed statistics; they are paid by bank transfer and invoiced on confirmation.
In five languages, down to the address bar
A listing in German, at /de/anzeigen/…. Navigation, labels and equipment are German; further down, the seller's description stays in Romanian, and the page says so.
- Every address is translated: /ro/anunturi, /en/listings, /de/anzeigen, /fr/annonces and /es/anuncios, and category pages such as /de/wohnmobile/cluj-napoca.
- No fake translations. A guide or landing page that has not been written in German returns 404 on the German site rather than showing Romanian text under a German address, and search engines are told only about the versions that exist. What sellers write is not machine-translated, and search works across every language anyway.
- E-mails follow the reader. A Romanian moderator blocking a German seller's listing sends the seller a German e-mail.
- The admin console speaks Romanian and English — the languages of the team that runs it.
For the team: a back office that runs the business
The console's front page, with sample figures. The money tile reads zero because no payment has been confirmed, which is the true answer before launch.
- Moderation. A queue with waiting times, keyboard shortcuts and a review panel. A moderator can approve, correct the title, price, category or town (the before and after are recorded), block or remove — always with one of eleven reasons, translated into all five languages, which the seller receives by e-mail. Two moderators cannot decide the same listing: every decision is checked against the version the moderator actually saw.
- Clients. Search, filter, block and unblock. Blocking takes an account's live listings down with it, and unblocking puts each one back exactly where it was. Staff can send a password-reset link and nothing else: they never see or set a password.
- Payments and packages. Confirm or reverse bank transfers, grant a package, and publish new packages through a three-step wizard with separate euro and lei prices — round numbers that do not move with the exchange rate.
- Six reports. Revenue, the invoice register, subscriptions, clients, listings and moderation, each exportable to Excel or PDF, with periods calculated on Bucharest time.
- Everything else a marketplace needs. A content system for guides, landing pages and legal documents; the makes, models, counties and towns behind every form, maintained through a CSV import that shows a dry-run report before anything changes; exchange-rate history; staff and roles; an audit log; and background-job status.
The moderation queue: the seller, the price in its original currency, how long each listing has waited and how many photographs it carries.
Why it is built this way
1. The architecture was written down before the code. A 2,373-line architecture document, now in its 69th revision, sets out the layering, the data model, security, every module's rules and 42 numbered decisions, each with its rationale and almost all with the alternative that was set aside. An implementation plan turns it into ordered tasks, each naming the sections to read, the files to build and the tests to write. When a change touches something the document catalogues, the document is updated in the same pull request; where the two disagree, the document wins. The trade-off is time spent before anything runs. The return is that every later change has a written rule to be checked against.
2. One API, in layers the build enforces. The back end is a single ASP.NET Core application in five projects — domain, data access, business services, the web API and a separate project for third-party adapters — with ten feature modules repeated in every layer. Architecture tests fail the build if a controller reaches for the data layer or the business layer depends on Entity Framework. E-mail, file storage, image processing and the PDF and Excel exports sit behind interfaces the business layer owns, so every third party can be replaced, and faked in tests. The trade-off is less freedom inside each feature, in exchange for a codebase where every module looks like every other.
3. Server-rendered where search engines look, browser-rendered where they do not. Home, search, listing, category and guide pages are rendered on the server so they can be indexed and shared; account and admin screens render in the browser and are kept out of search engines. The owner chose Azure Static Web Apps' hybrid mode, which Microsoft still labels a preview, so it went in with its limits written down and tested first: a hosting spike before any screen was built, and an exit path — the same build on App Service — documented and not needed so far. One limit shapes the product: pages cannot be refreshed on demand, so freshness is time-based. A listing page is at most a minute old.
4. Offline payments now, a payment gateway later. With no payment provider, an order produces a bank-transfer reference and instructions, and nothing is activated until a member of staff confirms that the money has arrived. Confirming is idempotent, reversing needs a reason and issues a credit note, and unpaid orders lapse after seven days. The gateway interface is designed, so adding a provider is an addition rather than a rewrite. The trade-off is a person in the loop for every payment. Bank details live in configuration, never in code, and a currency without a configured account refuses orders outright: instructions with a blank IBAN are worse than a refusal.
5. Five languages as a rule of the build, not a phase. Every public string ships in all five languages — and every admin string in Romanian and English — in the pull request that introduces it, and continuous integration blocks a missing key, a mismatched placeholder or an untranslated copy. A second check catches drift in style: formal address, British spelling in English, the Romanian comma-below letters, French spacing before punctuation, and labels that have outgrown the space they are given. The trade-off is translation work in every change. The alternative — translating at the end — is how multilingual sites end up half-translated.
6. Local-first development. PostgreSQL with PostGIS runs natively on the developer's machine, storage runs in an emulator and e-mails are written to files: local development needs no Azure resources and no internet connection. Integration tests run against a real PostgreSQL database — a throwaway one locally, a container in CI. The trade-off is that every machine needs PostgreSQL installed.
How it works
Two Azure applications, one database, and a proxy that keeps the sign-in cookie first-party. Select the diagram to open it full size.
Follow one listing through the system:
- Draft. A seller starts a listing. Only the vehicle type is needed to save it; everything else can wait for the submit.
- Photographs. For each file, the browser asks the API for a signed upload address that works for one file, over HTTPS, for a few minutes, and only for the image type it declared — then sends the bytes straight to Blob Storage. The photo's database row exists before the file does, so an abandoned upload is noticed and marked failed rather than left pending forever.
- Processing. A background job decodes the real bytes rather than trusting the file's name, removes metadata including location, writes WebP versions 400, 800 and 1,600 pixels wide, and marks the photograph ready.
- Submit and review. Submitting checks completeness and the seller's package limit, and the listing joins the moderation queue. The moderator's decision is recorded with its reason, audited, and e-mailed to the seller in their own language.
- Live. The listing can be found by distance, by budget in either currency and by free text. Its page is rendered on the server, and views are counted once per visitor per day, with crawlers left out.
- Contact. A buyer reveals the phone number or starts a conversation. A message left unread for ten minutes triggers an e-mail.
- The end. Marked sold, the listing shows a "sold" banner for thirty days and then answers 410 Gone. Otherwise it expires after sixty days, after a reminder, and an unchanged listing can be renewed in one click without going back through moderation.
Engineering challenges we solved
Keeping users signed in when the API lives on Azure's own domain
The risk: the API keeps Azure's default address, and that domain is on the Public Suffix List — so browsers treat a cookie it sets as belonging to a different site from the front end. Safari and Firefox block those, so the secure way of keeping people signed in would silently fail for a large share of visitors.
- Browsers never call the API directly. The front end exposes the API on its own origin, under /api, and forwards each request at runtime, so the sign-in cookie is first-party everywhere.
- The forwarding is authenticated. The proxy adds a secret header, and without it the API does not trust the client address it is given — which is what makes per-address rate limits mean anything.
- The hosting was tested before it was trusted. A spike on Static Web Apps found three things the documentation does not say: a rewrite in the framework's configuration freezes the API's address at build time; the standalone server runs the proxy twice per request, which produced redirect loops on Romanian addresses until it was guarded against; and page caching is silently ignored on routes that the translation library makes dynamic. Each one is now designed around or guarded against.
- End-to-end tests run on the server that is deployed, in Chromium, WebKit and a mobile profile, because the cookie flow is exactly the thing most likely to behave differently in Safari.
The result: the sign-in cookie is first-party by construction, so browsers that block third-party cookies have nothing to block — and the API needs no custom domain.
Refresh tokens that catch a thief, not a second tab
The risk: long sign-ins need refresh tokens, and refresh tokens are the credential worth stealing. Rotating them catches a replayed token — but a naive rotation signs people out whenever two tabs refresh at the same moment.
- Short-lived access, protected refresh. The access token lives for fifteen minutes and only in memory. The refresh token sits in an HttpOnly cookie scoped to the sign-in endpoints, and the database stores only a SHA-256 hash of it, so a leaked database holds no usable session.
- Every refresh token works once. Presenting one that has already been rotated revokes its whole family, writes an audit entry and increments a dedicated counter for alerting — and the revocation is saved before the request is refused.
- A race is not a theft. When two tabs refresh at once, the row version makes the slower request lose, and it is refused without touching the family. In the browser, refreshes are coordinated across tabs, so it rarely happens at all.
- Old sessions end when they should. Each token remembers the account's security stamp; a password reset or a role change moves the stamp and ends every session that predates it. A reset link proves that someone can read the inbox, not that the device in front of them should stay signed in, so a reset starts no session.
The result: a stolen token is detected and contained, and nobody is signed out for having two tabs open.
Two currencies that are never added together
The risk: vehicles are priced in euro or lei, packages are paid in either, and the accountant works in lei. Code that mixes the two produces totals that are wrong by the exchange rate — and reports that change every time they are opened.
- Money knows its currency. Amounts are a value type, and adding euro to lei throws an error instead of producing a number.
- One official rate a day. The National Bank of Romania publishes its rate in the early afternoon, at a minute that varies, so a job checks hourly through the afternoon on weekdays until the day's rate is stored. A rate entered by hand is audited and never overwritten by a later fetch.
- The lei value is fixed when the money arrives. When a payment in euro is confirmed, its lei equivalent is recorded once, at the rate of the day the money arrived, so a report reads the same tomorrow as today. Invoices in euro also print the lei equivalent and the rate used.
- Prices stay round. Listing prices are kept in the currency the seller chose, and staff set package prices in both currencies rather than converting them daily. A budget in lei is converted once, before the search runs.
- Reports keep the currencies apart. Totals are shown in lei with the split per currency, and a monthly figure that would have to add the two is labelled an estimate.
The result: every amount on an invoice, a report or a screen can be traced back to what was charged and the rate of the day.
Invoice numbers with no gaps, even under load
The risk: invoices need consecutive numbers. Taking the highest number and adding one hands two simultaneous confirmations the same number; reserving a number outside the transaction leaves a hole when something fails — and a hole is the first thing an auditor asks about.
- One counter, one statement, one transaction. The next number comes from a single update that returns the new value, inside the transaction that writes the invoice: both happen, or neither does.
- Tested under concurrency. Twenty invoices issued in parallel come out numbered contiguously, and that is a test.
- Mistakes become credit notes, not edits. An issued invoice never changes, and the company details printed on it are frozen at issue: an invoice issued in March keeps the name the company had in March.
- Checked from the other side. The invoice-register report — the accountant's export — runs a gap check over the whole series.
- Diacritics that survive. The PDF embeds its fonts, so Romanian names render correctly on the Linux server that produces it.
The result: a sequence an accountant can rely on, proven by a test rather than promised in a comment.
Search that forgives missing diacritics and understands distance
The risk: buyers type "Brasov" as often as "Brașov", and a radius search that is slow or subtly wrong makes the whole site feel broken.
- Accents are removed once, in the database. The searchable text is a generated column built with an unaccenting function, so it cannot drift from the listing, and every query is unaccented with the same function. The function is wrapped so that it can honestly be declared immutable, which PostgreSQL requires of a generated column.
- Distance is computed where the data is. Towns carry coordinates, radius search uses PostGIS with a spatial index, and a radius always overrides the town it is centred on — otherwise "Cluj + 50 km" would quietly return only Cluj.
- Paging that holds still. Every sort order ends with the listing's identifier, so a listing does not jump between pages as you move through the results.
- One definition of a search. Search, saved searches and landing pages share a single criteria shape, validated in one place.
- Caught by the architecture tests. The first version returned the data layer's type straight out of the controller, and the build failed until it went through the service layer.
The result: search that behaves the way people type, in every language, where distance means distance.
Privacy and access that are tested, not promised
The risk: in a marketplace, the easiest leak is not a breach. It is a phone number in a page's source, an error message that confirms someone else's listing exists, or a new endpoint that nobody decided who may call.
- The phone number is not in the payload. The public listing type has no phone or e-mail field, and a test reads the type's properties to prove it.
- Not yours looks like not there. Someone else's listing, session or conversation answers 404, never 403 — a 403 would confirm that it exists.
- Every endpoint has an access decision. A table lists every route against every role, plus anonymous callers, and the build fails both ways: an endpoint with no row, or a row with no endpoint.
- Logs are searched for secrets. A test records every line the application logs during a full registration and sign-in, and searches it for the password and the issued token.
- Uploads cannot become pages. A signed upload address is pinned to an image type, so a photo slot cannot be used to serve an HTML page from the platform's own storage.
The result: the privacy rules live in the test suite, so a new endpoint cannot ship without someone deciding who may call it.
How Coderlytics delivered it
Architecture first, then a plan that cannot be misread. Before the first line of code there was a Figma design of 23 screens, the written architecture and an ordered implementation plan. Each task names the architecture sections to read, the files to build, the tests to write, the checks to pass and the tasks it depends on. What only a person could do — the Azure subscription, the Google and Apple sign-in credentials, bank details, the brand name, the legal texts — was listed separately as owner prerequisites. Every task blocked by one was built against a documented fake and marked, so nothing waited.
AI implementers, human decisions, executable rules. The implementation was carried out by AI coding agents — Anthropic's Claude models — one task at a time, each on its own branch and pull request, a decision recorded in its own right for traceability. The rules they worked under are written into the repository as project skills and a fixed working loop: read the named sections before writing code, never invent scope, never weaken a failing test, ship every string in five languages, update the architecture in the same change, and leave security- and money-critical tasks to the most capable model. The rules a machine can check, the build enforces. Coderlytics owned the architecture, the plan, the decisions taken with the product owner, and the gates every change had to pass.
A history that reads like an engineering log. Each pull request says what changed, why, what it found and which checks passed — "1280 tests pass, build clean under -warnaserror, format clean" is a typical last line. Defects are written up as findings, next to the test that now holds the line.
Deployed early enough to learn from it. The first deployment to Azure turned up four defects between a green build and a running API, fixed in a single pull request the same day:
- A migration bundle that would not build. Two parts of the code decided whether background jobs were switched on from two different sources of configuration, and they disagreed only in the self-contained build the migration bundle uses. The code now asks the one question that matters: are the job services actually registered in this process?
- A deployment step with its arguments the wrong way round. It failed safe — the temporary database access rule it should have created was never opened, which was confirmed by listing the rules afterwards.
- A pipeline that could not read the vault. Rather than give the pipeline access to every secret, the migration connection string is now composed from values the pipeline can already read.
- An API that refused to start. The token settings had no source in any environment. Start-up validation did exactly what it was built for: it stopped the container instead of serving traffic with a broken configuration.
Two more issues surfaced only because the environment existed, and each now has a test guarding it: telemetry that was specified but never connected — a meter with no listener accepts every measurement and silently drops it — and a server component calling into a browser-only module, which took the whole content system down while every page still answered 200.
| Day | What merged |
|---|---|
| Sunday 20 September, from the evening | The architecture, the plan and the project skills; the solution skeleton and back-end CI; errors, validation, rate limiting and security headers; the architecture tests; the data layer; and the whole password and token lifecycle — 18 pull requests |
| Monday 21 September | Roles and the route-by-role access test; Google and Apple sign-in; reference data, listings, photographs, moderation and the admin APIs; search, landing pages and comparison; favourites, saved searches and messaging; packages, orders, invoices, promotions and exports; the six reports; guides and legal pages; the hardening pass and the seed data; then the hosting spike, the front end's foundation, the sign-in screens and the home page — 43 pull requests |
| Tuesday 22 September | Every public screen, the seller's flows and the complete admin console; then the translation review, the accessibility pass, the end-to-end suite and the design's photography — 29 pull requests |
| Wednesday 23 September | The Azure environment in Bicep, OIDC and the deployment workflows, e-mail through Communication Services; the first deployment and what it taught; telemetry and logs in Application Insights; and a language switcher with the Coderlytics credit in the footer — 17 pull requests |
Four days, 107 pull requests. Select the diagram to open it full size.
By the numbers
| Build window | 20–23 September 2026 |
| Pull requests merged | 107 |
| Planned tasks complete | 98 of 101 |
| Automated tests | 1,280 back end · 886 front end, plus a Playwright end-to-end suite |
| API operations | 169, in one OpenAPI document that the front end's client is generated from |
| Architecture document | 2,373 lines · 69 revisions · 42 recorded decisions |
| Languages | 5 — 1,470 interface strings in Romanian and English, 838 in German, French and Spanish, which do not carry the admin console |
| Screens | 23 from the Figma design, 26 more built from its design language |
| Background jobs | 10 |
| Reference data shipped | 42 counties · 162 towns · 65 makes · 318 models |
| Code | ~38,000 lines of C# and ~28,000 of TypeScript, plus ~40,000 lines of tests |
| Stored cloud credentials | 0 — the pipeline signs in to Azure over OIDC |
Figures as of 23 September 2026.
What comes next
- The name, then production. The brand name and domain are the last owner decision in the way. They unlock the production environment, the e-mail sender domain and production sign-in with Google and Apple.
- Monitoring on top of the telemetry. Metrics and logs now reach Application Insights; availability tests, alerts and dashboards come next.
- The launch checklist. A legal review of the English-only legal pages and of data retention, and the remaining security-checklist items that can only be verified against production.
- Designed, deliberately not built. Dealer accounts, online payments and a mobile app. Each already has a place in the architecture: an account type that dealers extend rather than replace, a payment-gateway interface, and an API that can hand a mobile client its refresh token directly.
What this shows
Three days is the headline, but it is not the point. The point is what made three days possible without cutting corners: an architecture written down before the code, every decision recorded with its reason, a plan broken into tasks small enough to verify, and rules the build enforces rather than a reviewer has to remember — the layering, an access decision for every endpoint, five languages, the API contract. When the first deployment found what no test could, each lesson went back into the tests.
The approach carries to any platform that has to be right on day one: decide first, write it down, make the rules executable, and deploy early enough for the environment to teach you something.
If you are planning a marketplace, a portal or any product with more rules than screens, talk to us.
Work with us
Planning a platform that has to be right on day one?
Tell us what you are building. We will come back with how we would architect, deliver, and operate it.
← Back to Work




