DEV Community

Ricardo Saumeth
Ricardo Saumeth

Posted on

๐“๐ก๐ž ๐‡๐š๐ง๐๐ฅ๐ž๐ซ ๐๐š๐ญ๐ญ๐ž๐ซ๐ง: ๐‡๐จ๐ฐ ๐’๐ž๐ง๐ข๐จ๐ซ ๐„๐ง๐ ๐ข๐ง๐ž๐ž๐ซ๐ฌ ๐Š๐ž๐ž๐ฉ ๐–๐ž๐›๐’๐จ๐œ๐ค๐ž๐ญ ๐‚๐จ๐๐ž ๐’๐œ๐š๐ฅ๐š๐›๐ฅ๐ž

Most WebSocket codebases start the same way: one onmessage callback, a few if statementsโ€ฆ and before long youโ€™re staring at a 300โ€‘line switch statement handling trades, tickers, books, candles, heartbeats โ€” all in one place.
It worksโ€ฆ until you need to test it, extend it, or debug it at 2am.
Thereโ€™s a better pattern.

๐“๐ก๐ž ๐‘๐ž๐š๐ฅ ๐๐ซ๐จ๐›๐ฅ๐ž๐ฆ
WebSocket messages arrive as raw arrays or objects with minimal type information.
The naive approach couples routing and processing in one giant function.
That makes the code:
Hard to test
Hard to extend
Hard to debug
Easy to break

๐“๐ก๐ž ๐‡๐š๐ง๐๐ฅ๐ž๐ซ ๐๐š๐ญ๐ญ๐ž๐ซ๐ง
The idea is simple: separate routing from processing.

๐Ÿ๏ธโƒฃ ๐Ž๐ง๐ž ๐ก๐š๐ง๐๐ฅ๐ž๐ซ ๐ฉ๐ž๐ซ ๐œ๐ก๐š๐ง๐ง๐ž๐ฅ
Each channel gets its own pure function โ€” no WebSocket context, no global state, no side effects beyond dispatching.

export const handleTradesData = (parsed, subscription, dispatch) => {
const currencyPair = subscription.request.symbol.slice(1)

if (Array.isArray(parsed[1])) {
const trades = parsed[1]
.sort((a, b) => b[1] - a[1])
.map(([id, ts, amount, price]) => ({ id, ts, amount, price }))
dispatch(tradesSnapshotReducer({ currencyPair, trades }))
} else {
const [id, ts, amount, price] = parsed[2]
dispatch(tradesUpdateReducer({ currencyPair, trade: { id, ts, amount, price } }))
}
}

Each handler has one job. Nothing more.

๐Ÿ๏ธโƒฃ ๐€ ๐ฅ๐จ๐จ๐ค๐ฎ๐ฉ ๐ฆ๐š๐ฉ ๐š๐ฌ ๐ญ๐ก๐ž ๐ซ๐จ๐ฎ๐ญ๐ž๐ซ
The middleware doesnโ€™t need to know what a โ€œtradeโ€ or โ€œcandleโ€ is โ€” it just picks the right handler.

export const handlers = {
TRADES: handleTradesData,
TICKER: handleTickerData,
BOOK: handleBookData,
CANDLES: handleCandlesData,
}
Adding a new channel becomes a twoโ€‘line change.

๐Ÿ‘๏ธโƒฃ ๐€ ๐ญ๐ก๐ข๐ง, ๐ฉ๐ซ๐ž๐๐ข๐œ๐ญ๐š๐›๐ฅ๐ž ๐ฆ๐ข๐๐๐ฅ๐ž๐ฐ๐š๐ซ๐ž
The middleware handles crossโ€‘cutting concerns (latency, stale detection, heartbeats) and delegates everything else.

const handler = handlers[subscription.channel]
if (!handler) return

handler(parsedData, subscription, store.dispatch)

๐–๐ก๐ฒ ๐“๐ก๐ข๐ฌ ๐Œ๐š๐ญ๐ญ๐ž๐ซ๐ฌ
Because now everything is testable in isolation:

it('handles trades snapshot', () => {
const dispatch = vi.fn()
handleTradesData(mockData, mockSubscription, dispatch)
expect(dispatch).toHaveBeenCalled()
})
No WebSocket server.
No Redux store.
No global state.
Just a function.

๐“๐ก๐ž ๐“๐š๐ค๐ž๐š๐ฐ๐š๐ฒ
This pattern is the Single Responsibility Principle applied at the architecture level:
Middleware decides when to process
Handlers decide how to process
Adding features becomes additive, not destructive
Debugging becomes targeted, not chaotic
This is the difference between code that works and code that scales.

๐—ช๐—ฟ๐—ถ๐˜๐˜๐—ฒ๐—ป ๐—ฏ๐˜† ๐—ฅ๐—ถ๐—ฐ๐—ฎ๐—ฟ๐—ฑ๐—ผ ๐—ฆ๐—ฎ๐˜‚๐—บ๐—ฒ๐˜๐—ต
๐—ฆ๐—ฒ๐—ป๐—ถ๐—ผ๐—ฟ ๐—™๐—ฟ๐—ผ๐—ป๐˜โ€‘๐—˜๐—ป๐—ฑ ๐—˜๐—ป๐—ด๐—ถ๐—ป๐—ฒ๐—ฒ๐—ฟ | ๐—ฅ๐—ฒ๐—ฎ๐—นโ€‘๐—ง๐—ถ๐—บ๐—ฒ ๐—จ๐—œ ๐—ฆ๐—ฝ๐—ฒ๐—ฐ๐—ถ๐—ฎ๐—น๐—ถ๐˜€๐˜

Top comments (0)