Notion shares a large amount of code between the front-end and back-end. We have many algorithms, collections, helpers, etc that we share. Here's an example, we have a shared loadPageChunk function that takes a cursor and a loader implementation, then traverses our data graph to gather the data needed to render part of a page.
// shared code - implement the algorithm
export async function loadPageChunk(
args: LoadPageChunkArgs,
loadRecordValue: loadRecordValueFn
) {
// ...
}
// client code - use the algorithm, provide client-specific IO
// eg on Android we'd use Sqlite.
const records = await loadPageChunk(cursor, SqliteService.loadRecordValue)
// Server code - same, but use the server's data stores.
// Behind the scenes, these loaders batch, etc
const records = await loadPageChunk(cursor, useCache ? CacheService.loadRecordValue : PostgresService.loadRecordValue)
Even if we only shared types, there's a significant benefit. We try to push as much logic into the type system as we can; for example we use discriminating unions to define different groups of related types. Eg, we have a union type called ContentBlock that has all the specific block types that can have children, `Page | Text | Column | ...`, sharing this type and its helper functions like `isContentBlock(block: BlockValue): block is ContentBlock` means both our front-end and back-end code rely/expect/enforce the same invariants.