Same Core, Different Apps: Adapting Jolt's unified business logic to different platforms
As we launched Jolt incrementally across web, IDE extensions, and desktop, we faced a fundamental challenge: how could we avoid rewriting the same business logic for each platform while still leveraging platform-specific capabilities? Our solution was to build a unified core logic layer in TypeScript that adapts to different runtime environments through thin communication middlewares. This architecture has enabled us to maintain a single source of truth for business logic while shipping native experiences across VSCode, JetBrains, Electron, and the web, with 95% code reuse and minimal platform-specific code.
Sharing core business logic across multiple deployment environments and targets
Each platform brought unique technical requirements that made a unified approach essential. IDE extensions and desktop apps needed access to local file systems to track git changes and execute CLI commands, capabilities unavailable in browser environments. Data persistence varied across platforms, from browser localStorage
to IDE-specific storage APIs. Most critically, each of the native platforms had its own communication mechanism: VSCode's MessageEvent
API, JetBrains' Kotlin-to-webview bridge, and Electron's IPC. Our core logic layer abstracts these differences behind a consistent interface, allowing us to write business logic once while adapting to each platform's constraints and capabilities.
Scalable and Reusable Communication Middleware
The Challenge: Different Runtime Environments
Traditional web applications run entirely in the browser, making API requests through HTTP client libraries like Redux loaders, react-query, or SWR. But Jolt's IDE extensions and desktop app require deeper system integration. They need to execute git
CLI commands, access local file systems, and interact with IDE-specific APIs. These capabilities simply don't exist in browser environments.
This fundamental difference means we need our client layer to work in both browser and NodeJS runtimes, while maintaining a consistent developer experience across all platforms.
Our Solution: Platform-Specific Communication Middlewares
We solved this by introducing thin communication middleware layers that act as adapters between our core logic and UI layers. These middlewares handle all platform-specific communication details, allowing both our core business logic and UI components to remain completely agnostic to how messages are actually transmitted.
The key insight was that the core logic doesn't need to know whether it's running in NodeJS or the browser. It just needs to receive requests and return responses. Similarly, our SolidJS UI doesn't care how its requests reach the core logic, as long as it gets the data it needs.
How Each Platform Communicates
Each platform requires a different approach to bridge the core logic and UI:
- VSCode: Uses the extension's
MessageEvent
API to communicate between the Node-based extension host and the webview panel. This is VSCode's standard approach for extension-to-UI communication. - JetBrains: Takes a unique approach by running our TypeScript core logic as an HTTP server spawned by Kotlin. The Chrome-based webview (JCEF) communicates with this local server, bridging the JVM and JavaScript worlds.
- Desktop (Electron): Invokes the core logic layer directly in a Node subprocess and uses Electron's IPC mechanism for bi-directional communication with the UI renderer process.
- Web: The simplest case. Everything runs in the browser, so the communication layer is just direct function invocations. No serialization or message passing needed:
Ensuring Type Safety Across Boundaries
While TypeScript gives us type safety within each environment, we lose those guarantees when serializing data across process boundaries. VSCode's MessageEvent
, JetBrains' HTTP transport, and Electron's IPC all require serialization, which strips away type information.
We added Zod validation to restore confidence in our data shapes at these boundaries. Every message that crosses a communication layer gets validated, allowing us to catch type mismatches and data corruption early in development rather than in production.
Additional Platform Considerations
Beyond communication, each platform has unique storage requirements. While we could rely on browser localStorage
for the web app, our IDE extensions and desktop app needed durable storage that persists outside of IDE-specific contexts. We standardized on Conf for these platforms, giving us a consistent API for storing user preferences and small amounts of state.
This architecture has proven remarkably scalable. Each layer has a single responsibility, making it easy to add new platforms or modify existing ones without touching our business logic.
Handling Platform-Specific Capabilities
The Abstract Class Pattern
While our communication middleware abstracts our app-to-logic pipeline, we still need to address platform-specific capabilities. Git operations exemplify this challenge. Our IDE extensions and desktop app can execute git
commands to find local file changes, which supplies critical code context for Jolt. But the web app has no access to the local file system or git
CLI. We need a pattern that lets us write business logic once while gracefully handling these platform differences.
Abstract Classes: Same Interface, Different Implementations
We use TypeScript abstract
classes to define contracts that all platforms must implement, while allowing each platform to fulfill those contracts in its own way. This keeps our business logic clean and platform-agnostic. It simply calls methods without knowing or caring how they're implemented.
Here's how this plays out in practice with our chat service:
The sendNewChatMessage
method contains all our business logic: validation, data collection, API formatting. It doesn't care how getLocallyChangedFiles()
works; it just needs the result.
Client apps running in Node can provide rich git
information:
While the browser implementation simply returns an empty array:
This pattern lets us keep the core logic services as DRY as possible while still leaving room for per-platform customization. When we add a new platform, we just need to implement these abstract methods. The business logic comes along for free.
All in on TypeScript, with a little Kotlin
The JetBrains Exception
JetBrains IDEs present a unique challenge. Unlike VSCode's JavaScript extension API or Electron's Node.js environment, IntelliJ-based IDEs run on the JVM and expose their APIs through Java/Kotlin. We could have rewritten our core logic in Kotlin, but that would mean maintaining two separate codebases with identical business logic, exactly what we set out to avoid.
Instead, we chose a hybrid approach: keep all business logic in TypeScript and use Kotlin solely as a thin bridge to IDE-specific features. This means our JetBrains plugin is actually our shared TypeScript code, with Kotlin handling only what it must: spawning the Node process, managing the HTTP server, and accessing IntelliJ platform APIs.
Building the Bridge
The integration works by establishing a communication channel between Kotlin and our TypeScript core running in a local HTTP server. On the UI side, we use JetBrains' Chrome Embedded Framework (JCEF) which lets us render our SolidJS interface:
The messageRouterHandler
is a Kotlin @Service
that acts as our bridge. It switches on requests from the UI and either forwards them to our TypeScript HTTP server or handles IDE-specific requests directly. When the UI needs the list of open files or project errors, Kotlin fetches this from IntelliJ's APIs. When it needs to analyze code or prepare a response, it delegates to our TypeScript core.
The Best of Both Worlds
This architecture gives us remarkable leverage. When we add a new feature to Jolt, it automatically works across all platforms, including JetBrains. The Kotlin layer rarely needs updates unless we're adding new IDE-specific integrations. Bug fixes, business logic changes, and new capabilities flow through to JetBrains users without touching a line of Kotlin.
The hybrid approach also simplifies testing and development. We can test our core logic independently of any IDE, and JetBrains-specific developers only need to focus on the thin integration layer rather than understanding our entire business domain.
The Payoff: Unified Architecture in Practice
Shipping Features, Not Duplicating Code
The true test of any architecture is how it performs in the real world. When we implement a new feature in Jolt's core logic, whether it's improved code analysis, a new chat capability, or better context handling, that feature immediately works across all platforms. There's no porting phase, no platform-specific rewrites, and no risk of behavioral differences between platforms.
Lessons from the Architecture
The most valuable lesson has been the importance of drawing clear boundaries early. By strictly separating platform concerns (communication, storage, system access) from business logic, we've avoided the gradual pollution that often creeps into multi-platform codebases. The abstract class pattern for platform capabilities has proven particularly powerful. It makes platform differences explicit and manageable rather than scattered throughout the code.
The investment in type safety at platform boundaries through Zod has also paid dividends. While it added some initial complexity, catching data shape mismatches during development rather than in production has been invaluable, especially given the variety of serialization mechanisms across platforms.
Looking Ahead
This architecture positions us well for future expansion. Adding a new platform, whether it's a different IDE or a CLI tool, is a bounded problem: implement the communication middleware, provide platform-specific capability implementations, and the rest comes for free. As Jolt's capabilities grow, we can focus on building features once rather than maintaining feature parity across multiple codebases.
That 95% code reuse translates directly to fewer bugs, less maintenance overhead, and more time spent on actual features instead of platform-specific implementations.