RESTEventsStreams

REST, Events & Streams: Three Contracts for One Weather Service

Same coordinate, same domain logic, three completely different promises to three different consumers. A worked TypeScript/Node example — and the discipline that keeps three contracts from quietly becoming one leaky one.

A browser calls navigator.geolocation, gets a latitude and longitude, and sends it to a Weather Service. Simple enough — until three other services need that reading, and each one needs it on different terms. A dashboard wants an answer right now, synchronously, in the middle of a page render. A notification service doesn't want to ask at all — it wants to be told, whenever a reading crosses a threshold worth acting on. A live map wants a steady drip of readings as the user moves, for as long as the connection stays open.

That's not one integration problem. It's three, and they call for three different contract shapes: a REST request/response, a published event, and a stream of frames. The trap is building all three off the same DTO because it's sitting right there. The fix is one domain function underneath, and three contracts on top that are allowed to evolve completely independently of each other.

Same signal, three shapes

Sketched as waveforms, the difference is visual as much as architectural: REST is a single deliberate pulse and a wait. An event is a sparse, unpredictable spike — most of the time, nothing. A stream is continuous, whether or not anyone's currently looking at it.

REST
Dashboardrequest → wait → response
Event
Notification servicesilent, then a spike worth acting on
Stream
Live mapcontinuous frames while connected

One domain core, underneath all three

Every adapter calls the same function. None of them import each other's types. This is the part that has to hold, or "three contracts" quietly becomes "one DTO with three names."

domain/weather.service.ts — the only place that knows how weather actually gets fetched
export interface WeatherSnapshot {
  temperatureC: number;
  condition: "clear" | "cloudy" | "rain" | "storm";
}

export async function getWeatherForCoordinates(
  lat: number,
  lon: number
): Promise<WeatherSnapshot> {
  const res = await fetch(
    `https://api.weatherprovider.com/v1/point?lat=${lat}&lon=${lon}`
  );
  const data = await res.json();
  return { temperatureC: data.temp_c, condition: data.condition };
}

REST — ask once, get an answer

Request / Response

For the dashboard that just rendered and needs a number to show now. The contract is a query shape in, a response shape out — validated at the edge with zod so a malformed lat/lon never reaches the domain function at all.

contracts/rest.contract.ts
export interface WeatherResponse {
  location: { lat: number; lon: number };
  temperatureC: number;
  condition: string;
  observedAt: string; // ISO timestamp
}
rest/weather.controller.ts
import { Router } from "express";
import { z } from "zod";
import { getWeatherForCoordinates } from "../domain/weather.service";

const querySchema = z.object({
  lat: z.coerce.number().min(-90).max(90),
  lon: z.coerce.number().min(-180).max(180),
});

export const weatherRouter = Router();

weatherRouter.get("/weather", async (req, res) => {
  const parsed = querySchema.safeParse(req.query);
  if (!parsed.success) {
    return res.status(400).json({ error: "invalid coordinates" });
  }
  const snapshot = await getWeatherForCoordinates(parsed.data.lat, parsed.data.lon);
  res.json({
    location: { lat: parsed.data.lat, lon: parsed.data.lon },
    temperatureC: snapshot.temperatureC,
    condition: snapshot.condition,
    observedAt: new Date().toISOString(),
  } satisfies WeatherResponse);
});

Events — tell whoever's listening

Publish / Subscribe

The notification service never asks for weather. It subscribes to a fact: a reading was observed. The version tag in the event's type field is the whole compatibility strategy — a v2 payload ships as a new event type, not a breaking change to v1 subscribers who never opted in.

contracts/event.contract.ts
export interface WeatherObservedEvent {
  type: "weather.observed.v1";
  payload: {
    userId: string;
    location: { lat: number; lon: number };
    temperatureC: number;
    condition: string;
  };
  observedAt: string;
}
events/weather.publisher.ts
import { eventBus } from "../infra/event-bus";
import { getWeatherForCoordinates } from "../domain/weather.service";
import type { WeatherObservedEvent } from "../contracts/event.contract";

export async function publishWeatherObserved(userId: string, lat: number, lon: number) {
  const snapshot = await getWeatherForCoordinates(lat, lon);
  const event: WeatherObservedEvent = {
    type: "weather.observed.v1",
    payload: { userId, location: { lat, lon }, temperatureC: snapshot.temperatureC, condition: snapshot.condition },
    observedAt: new Date().toISOString(),
  };
  await eventBus.publish(event);
}
notifications/weather.subscriber.ts
eventBus.subscribe("weather.observed.v1", async (event: WeatherObservedEvent) => {
  if (event.payload.condition === "storm") {
    await notifyUser(event.payload.userId, "Storm warning near your location");
  }
});

Stream — keep frames coming while I'm connected

Continuous / WebSocket

The live map isn't asking a question or waiting for a fact — it wants a heartbeat of frames for as long as the socket stays open, as the browser's coordinates change underneath it. The seq field matters more here than it looks: it's what lets the client detect a dropped frame instead of silently rendering a stale position.

contracts/stream.contract.ts
export interface WeatherStreamFrame {
  seq: number;
  location: { lat: number; lon: number };
  temperatureC: number;
  condition: string;
  emittedAt: string;
}
stream/weather.stream.ts
import { WebSocketServer } from "ws";
import { getWeatherForCoordinates } from "../domain/weather.service";
import type { WeatherStreamFrame } from "../contracts/stream.contract";

export function attachWeatherStream(wss: WebSocketServer) {
  wss.on("connection", (socket) => {
    let seq = 0;
    socket.on("message", async (raw) => {
      const { lat, lon } = JSON.parse(raw.toString());
      const snapshot = await getWeatherForCoordinates(lat, lon);
      const frame: WeatherStreamFrame = {
        seq: seq++,
        location: { lat, lon },
        temperatureC: snapshot.temperatureC,
        condition: snapshot.condition,
        emittedAt: new Date().toISOString(),
      };
      socket.send(JSON.stringify(frame));
    });
  });
}
browser — feeding coordinates in as the user moves
navigator.geolocation.watchPosition((pos) => {
  socket.send(JSON.stringify({
    lat: pos.coords.latitude,
    lon: pos.coords.longitude,
  }));
});

Keeping three contracts honest

The pattern only pays off if the three stay genuinely independent. A few rules that keep it that way:

No contract imports another contract's types. WeatherResponse, WeatherObservedEvent, and WeatherStreamFrame happen to share fields today because they share a source. That's a coincidence, not a dependency — each is free to add, rename, or drop a field without a cross-team conversation about the other two.

Version what changes independently. REST gets versioned in the URL or a header. Events get versioned in the type string, right where weather.observed.v1 lives — so a new shape ships as a new subscribable fact, and existing subscribers are simply never routed to it. Streams get versioned in the frame itself, since a socket is a long-lived connection that can't renegotiate its contract mid-flight.

Only one thing is allowed to know how weather actually gets fetched. All three adapters call the same getWeatherForCoordinates. If the upstream provider changes tomorrow, that's a one-file change — not a REST fix, an event fix, and a stream fix, done three times with three chances to drift out of sync.

The pattern underneath

REST, events, and streams aren't three implementations of the same interface — they're three different promises about time. REST promises an answer now. An event promises you'll be told when something's true. A stream promises a heartbeat for as long as you're listening. Picking the wrong one doesn't just cost performance — it makes a consumer wait for something that was never going to arrive on that timeline.

Next in this series Schema registries and contract testing — catching a breaking change to any of these three before it ships, instead of after a subscriber falls silent in production.