skip to content
jade bonifaciojb_
  • work
  • about
  • contact
  • resume
© 2026 jade bonifacio·manila ·
  • work
  • about
  • contact
  • resume
←back to work

ScoutBoard

2026 · side project

A realtime marketplace for buying and selling small businesses, built in five evenings.

nestjs · mongodb · redis · socket.io · next.js app router · tanstack query · openai

live demo ↗source ↗companion ↗
The ScoutBoard landing page: a dark green hero reading 'Buy a real business with real numbers.', a pill counting 349 live offers on the book, a most-watched card for Ledger Lane Bookkeeping asking $190,000, and a strip below it counting businesses listed, median asking, live offers and median multiple.
the landing page, with the offer book's live count in the corner

Why I built it

ScoutBoard is a marketplace where small businesses are listed for sale and buyers make offers on them, with the offers landing in realtime. It's the most complete backend I've built: caching with real invalidation rules, a message-driven realtime layer, an AI endpoint that returns structured data, and tests that run in CI.

The reason it exists is specific. After a first-round interview at a startup in exactly this domain, the feedback was that I needed deeper familiarity with their stack: NestJS, MongoDB, Redis, Socket.IO, TanStack, OpenAI. Months later, with a second round coming up, reading docs wasn't going to close that gap. So I gave myself five evenings to build something real on that stack and to make every architectural decision one I could defend out loud.

The interview didn't go anywhere. The project did. Every pattern in it is one I can now explain from experience rather than from a tutorial.

What it does

  • Businesses are listed with price, industry, and financials; buyers submit offers
  • A sortable, filterable listings table with live offer counts
  • Listing detail pages where new offers appear in realtime, no refresh — open two windows, post an offer in one, watch it land in the other
  • An AI listing analyst: one endpoint that returns a structured verdict on a listing — fair-value range, reasoning, a suggested offer — as JSON, cached per listing
  • A market simulator that posts a realistic offer (60–110% of asking) on a schedule, so the realtime layer has something to show. It is gated behind an env flag and goes through the real offers service, so validation, counters, invalidation, and broadcasts all fire and the demo cannot drift from production behavior
A ScoutBoard listing asking $350,000, its stat tiles and offer history on the left, and on the right an AI analysis panel marking it overpriced: a $250k–$300k fair-value range, a bar putting the asking price above that range, four lines of reasoning, and a suggested opening offer of $275,000.
the ai analyst's verdict, with a fair-value range and a suggested offer

Architecture

pnpm monorepo, backend and frontend as separate workspaces.

NestJS backend with Listings and Offers as separate modules. Offers reference Listings by ObjectId. Every DTO goes through a global ValidationPipe with forbidNonWhitelisted, so unexpected fields are rejected rather than silently dropped. Cross-module wiring — Offers needing to bump a counter on Listings — was my first real encounter with NestJS's module boundaries and exports.

MongoDB for listings and offers. The offer count on each listing is denormalized and maintained with $inc on every write, so the listings table doesn't need a join or a count query per row. Denormalized counters drift, so a reconciliation cron compares each stored count against the source of truth and repairs only the ones that moved. At real scale that per-listing loop becomes a single $group aggregation; at this size the loop is fine and I know where the upgrade lives.

Redis in two roles: a cache-aside layer over listing reads (60s TTL) and per-listing AI analyses (1h TTL), both invalidated on every write path that changes their inputs, and an atomic counter for per-listing view counts and per-key rate limiting on the offers endpoint. The TTLs are the safety net if an invalidation path is ever missed.

Socket.IO, two gateways: one for listings, one for offers. On the frontend, events are pushed directly into TanStack Query's cache with setQueryData, so the realtime update and the fetched data share one source of truth and the UI doesn't need a second state layer.

// A socket-pushed offer is written straight into the query cache, so the
// realtime update and the fetched data share one source of truth.
socket.on("offer:created", (offer: Offer) => {
  queryClient.setQueryData<Offer[]>(["offers", offer.listingId], (prev = []) =>
    prev.some((o) => o.id === offer.id) ? prev : [offer, ...prev],
  );
});

Next.js App Router frontend with TanStack Query and TanStack Table, shadcn/ui components.

The ScoutBoard marketplace under the heading 'Businesses for sale': category filter chips, a sort dropdown and a cards/rows toggle above a grid of listing cards, each with a photograph, industry and city, yearly revenue, asking multiple, asking price and a live offer count.
the marketplace, each card a small business for sale

OpenAI integration behind a provider-agnostic wrapper: base URL, model, and key come from environment variables, so swapping to another provider is a config change. The analyst prompt asks for strict JSON and the response is validated before it's cached.

Jest, 34 tests across the services, gateways, and controllers, on a fully mocked dependency graph (Mongoose models via getModelToken, Redis behind a provider token, gateways as stubs), so the suite runs in milliseconds with no infrastructure. The failure-path tests assert the absence of side effects: a rate-limited offer creates nothing, increments nothing, broadcasts nothing. GitHub Actions runs lint, tests, and a production build on every push.

Hard parts

Cache invalidation is asymmetric. Invalidating the listings cache when a listing is created is obvious. Invalidating it when an offer is created is not, but the listings table shows offer counts, so a stale cache means a stale count. The rule I landed on: invalidate what you can key, expire what you can't.

Realtime and fetched data fighting. Early on, a socket-pushed offer would arrive but not render, because the UI gated the offers list on offersCount > 0 from the fetched listing, which hadn't refetched yet. The fix was to trust the cache as the single source and stop deriving render conditions from a separate, slower field.

Denormalized counters lie eventually. $inc is atomic, but a failed request after the offer write and before the increment leaves the count wrong forever. The reconciliation cron exists because I'd rather correct drift on a schedule than pretend it can't happen.

Lesson: invalidate the cache entries you can key precisely; put a short expiry on the ones you can't.

What I'd change

  • Denormalized counter plus reconciliation cron is the pragmatic answer. The correct one is a transactional outbox or a proper event pipeline, which is more infrastructure than the project justified.
  • Two Socket.IO gateways only work on one instance. Scaling horizontally needs the Redis pub/sub adapter so events fan out across servers.
  • Tests stop at the backend. The frontend has none, and the backend suite is fully mocked, so nothing exercises a real MongoDB or Redis. Enough for CI to mean something, not enough to refactor fearlessly.
  • No accounts. Anyone can publish a listing or post an offer, so there is no seller side: no "my listings", no accepting or declining an offer, no way to tell two buyers apart beyond the name they typed.
next project →Enterprise platform work