DEV Community

Ayron Wohletz
Ayron Wohletz

Posted on • Updated on • Originally published at funtoimagine.com

An Electron app architecture

In my Electron app, I keep the Renderer and Main process decoupled, similar to a web app. The main difference is that instead of communicating over HTTP/Websockets, the client and server communicate with IPC. But that’s an implementation detail that I’ve hidden from the bulk of my code.

Here's a template repository that demonstrates this approach: https://github.com/awohletz/electron-prisma-template

The following diagram shows how the pieces fit together on the high level.

Client-server-like Electron app architecture. Main process separated from Renderer process. Main process exposes ipcRenderer methods to Renderer.

This architecture has these properties:

  • Bidirectional communication — both Main and Renderer can initiate communication to the other.
  • Leverages familiar libraries usually used in web apps — I don’t have to re-invent libraries and patterns already existing in the JavaScript ecosystem.
  • Main and renderer are decoupled.

Let me explain each property in more detail...

Bidirectional communication

To enable bidirectional communication between Main and Renderer, I expose two points in preload, rpc and receive:

contextBridge.exposeInMainWorld("ariNote", {
    rpc: (op: {
        type: "query" | "mutation" | "subscription";
        input: unknown;
        path: string;
    }) => ipcRenderer.invoke("rpc", op),
    receive: (channel: string, func: Function) => {
        const validChannels = ["app"];
        if (validChannels.includes(channel)) {
            // Deliberately strip event as it includes `sender`
            ipcRenderer.removeAllListeners(channel);
            ipcRenderer.on(channel, (event, ...args) => func(...args));
        }
    },
    appPlatform: process.platform,
});
Enter fullscreen mode Exit fullscreen mode

On top of these two exposed points, I have built a layer of abstraction. That layer lets the Renderer send requests to Main via tRPC queries and mutations. Under the hood, the layer uses the exposed rpc API to send those requests and get the response via ipcRenderer.invoke promise resolution. The Main process has a tRPC router that receives the request and resolves to the response. This all is described in more detail in Using React and tRPC with Electron.

Here’s an example of how this looks in usage. The Renderer uses tRPC hooks inside of its React components:

const workspace = *trpc*.useQuery(["workspace.byId", workspaceId]);
Enter fullscreen mode Exit fullscreen mode

And the tRPC router in Main has a corresponding resolver:

query("byId", {
    input: zid,
    async resolve({ctx, input: workspaceId}): Promise<Workspace> {
        const workspace = await ctx.prisma.workspace.findUnique({ //...
        //... omitted for brevity

        return {
            id: workspaceId,
            boxes
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

In essence, both sides use tRPC exactly as described in the tRPC docs. Creating a new API with tRPC is a joy. It provides full stack static typing without any code generation.

Main-initiated communication

As a mechanism separate from tRPC, Main can also initiate communication with Renderer by sending events with ipcRenderer.send. Renderer has a useEffect hook in a top-level component which listens to those events with the exposed ipcRenderer.on:

useEffect(() => {
    window.ariNote.receive("app", (event) => {
        console.log("Received event from main ", event);
        handleAction(event);
    });
}, [handleAction])
Enter fullscreen mode Exit fullscreen mode

I use this mechanism to handle events such as user clicking a native application menu. E.g. clicking the Help → About menu, which opens a React-driven modal in Renderer:

{
    label: i18nextMainBackend.t("About"),
    click: async () => {
        sendToRenderer(mainWindow.webContents, {
            action: "about"
        });
    }
},
Enter fullscreen mode Exit fullscreen mode

Or sending electron-updater events for the Renderer to respond to how it wishes (e.g. by showing a progress bar for download progress):

autoUpdater.on("download-progress", (progress: ProgressInfo) => {
    if (win?.webContents) {
        sendToRenderer(win.webContents, {
            action: "updateDownloadProgress",
            progress
        })
    }
});
Enter fullscreen mode Exit fullscreen mode

Familiar libraries

Since I’ve chosen an app architecture that acts like a web app, I can leverage existing libraries and patterns in the ecosystem.

Some of the libraries I use in Renderer:

Some of the libraries I use in Main:

Using Prisma and SQLite with Electron

Prisma posed a special challenge for using with Electron. See Github issues. It was still worth it though. Even with my relatively simple database schema, Prisma gives me quite a productivity boost compared to using raw SQL.

I actually started off using better-sqlite3 (the best SQLite library I could find for Node). better-sqlite3 is an awesome library. It’s just rather low-level for my use-case. I found myself coding a high-level client, manual TypeScript types, data mapping, etc. So I did some research on the ecosystem and found Prisma. Prisma handles all those things I had started hand-rolling, so it was an easy decision to switch.

I prefer Prisma to the other ORMs in the ecosystem, because it’s not object-oriented. It’s more data-oriented. For example, queries are just JSON objects, not some chaining API. Results are JSON objects that conform to TS interfaces, not instances of classes. That fits my functional-lite programming style better than having to come up with some class hierarchy.

The downside is the Prisma query engine and migration engine binaries increase my Electron app bundle size. I need those binaries to run Prisma migrate at runtime. As I'm a team of one, that's a tradeoff I'm willing to make in exchange for developer productivity. At least for now.

Main and renderer are decoupled

The Renderer code knows almost nothing of Electron or IPC. It has only the tiny integration points mentioned above to use tRPC and receive events from Main.

The tRPC router in Main likewise knows very little of Electron. It just uses Prisma to do CRUD. On occasion it calls Electron APIs for native features. But the tRPC structure itself knows nothing of this. For all it knows, it could be responding to an HTTP client.

Rationale

In most Electron tutorials I found, the main process exposes APIs to the renderer process, and the renderer process calls those APIs directly. So you might have a renderer process directly manipulating the database or interacting with the operating system, for example.

This is not a scalable pattern. The UI code will become coupled to details it shouldn’t have to worry about. Database CRUD, Electron APIs, and managing UI interaction are separate concerns.

Keeping a gateway between main and renderer, as in a traditional web app over HTTP, decouples those concerns. Decoupling allows the client and server code to change with minimal impact to each other. For example, if I refactor my database schema, I shouldn’t have to change a bunch of React components. The React components don’t need to know about the structure of the database — if I’m storing booleans as ints, what SQL queries to run, and so on. They only need to know about the information model of the domain entities, such as notes and links.

Summary

This is my first Electron app, and this architecture has served me well so far. It follows the well-established client/server paradigm, giving each side room to grow.

UPDATE: Here's a template repository that demonstrates this approach: https://github.com/awohletz/electron-prisma-template

What architecture did you choose for your Electron app? I'm curious to know, as I didn't find much opinion published online on Electron app architectures. Let's talk shop :)

Discussion (15)

Collapse
florianbepunkt profile image
Florian Bischoff

I would love to know more about how you setup Prisma, especially the build setup. I tinkered with Prisma and electron-forge for two days but was not able to get it to work. I think it has to do with my TypeScript/webpack setup, but not sure.

Collapse
awohletz profile image
Ayron Wohletz Author • Edited on

The key part with Prisma is that any of the binaries/engines that it uses have to be out of the ASAR. Prisma can't run them inside the ASAR.

Once packed outside the ASAR, I pass the engine path to the client:

export const prisma = new PrismaClient({
    // see https://github.com/prisma/prisma/discussions/5200
    __internal: {
        engine: {
            // @ts-expect-error internal prop
            binaryPath: qePath
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

Here are relevant parts from my electron-builder config:

directories:
  buildResources: resources
  output: packed
files:
  - dist/**/*
  - localization/!(locales)
  - prisma/**/*
  - resources/**/*
  # @prisma is not needed in the packed app unless I'm using prisma migrate
  - "!**/node_modules/@prisma/engines/introspection-engine*"
  - "!**/node_modules/@prisma/engines/migration-engine*"
  - "!**/node_modules/@prisma/engines/prisma-fmt*"
  - "!**/node_modules/@prisma/engines/query_engine-*"
  - "!**/node_modules/@prisma/engines/libquery_engine*"
  - "!**/node_modules/prisma/query_engine*"
  - "!**/node_modules/prisma/libquery_engine*"
  - "!**/node_modules/prisma/**/*.mjs"
extraResources: # Only if you need to run prisma migrate
  - node_modules/@prisma/engines/migration-engine*
  - node_modules/@prisma/engines/query*
  - node_modules/@prisma/engines/libquery*
win:
  target:
    - nsis
  asar:
    smartUnpack: false
  asarUnpack: # only if I need to run prisma migrate:
    - prisma
linux:
  target:
    - snap
    - AppImage
  asarUnpack: # only if I need to run prisma migrate:
    - prisma
mac:
  target:
    - target: dmg
      arch:
        - x64
        - arm64
    - target: zip # zip is required because of electron-userland/electron-builder#2199
      arch:
        - x64
        - arm64
  asarUnpack: # only if I need to run prisma migrate:
    - prisma
Enter fullscreen mode Exit fullscreen mode

Does this help?

Collapse
florianbepunkt profile image
Florian Bischoff

This and your comments in the github repo helped tremendously. You really paved the ways for using prisma in electron in a sensible matter. Thank you. My build problems were mainly related to electron forge/webpack config. I switched to a different boileterplate and now got everything set up properly. What I haven't tackled yet is migrations. It's next on my list.

Since there are many moving parts involved, would you mind if I create a starter repo based on github.com/electron-react-boilerpl... and link to this article and the github issues? I believe it will be easier for some people to see the full code and how things integrate with each other.

Also I had some different requirements.. I had my domain logic already setup in a functional way and wanted to provide access to db / file system / certain electron apis simply as an environment for my functions to execute within. Therefore I exposed prisma via context object in the preload script.

Thread Thread
awohletz profile image
Ayron Wohletz Author • Edited on

Ah, setting up a repo could be helpful for folks! I don’t mind at all. Let me know if I can help with it.

Thread Thread
florianbepunkt profile image
Florian Bischoff

Here we go: github.com/florianbepunkt/electron...

The boilerplate is a bit opinionated how native deps are handled, but this should give other's a starting point.

Collapse
florianbepunkt profile image
Florian Bischoff

Could you maybe share a gist of resolveIpcRequest as desribed in your post here: funtoimagine.com/blog/using-react-...

Collapse
florianbepunkt profile image
Florian Bischoff

To be more precise. In the following code taken from your article. What is the definition of the IpcRpcRequest type and how does the implementation of resolveIPCResponse and createContext look like? I really like the architectural approach, but both parts touch in trprc internals (unchartered territory for me).

ipcMain.handle("rpc", async (event, req: IpcRpcRequest) => {
    // console.log(arg)

    const output = await resolveIPCResponse({
        batching: {
            enabled: !!req.isBatch
        },
        req: req,
        router: appRouter,
        createContext: () => createContext({event, req})
    })

    return {
        ...output,
        id: req.id
    };
})
Enter fullscreen mode Exit fullscreen mode
Collapse
awohletz profile image
Ayron Wohletz Author

Sure, I have those definitions in a separate file: gist.github.com/awohletz/6efc9d8da...

Does this help? Let me know if there are any pieces I've left out.

Thread Thread
florianbepunkt profile image
Florian Bischoff

Thanks a lot. I tried this architecture with a prototype and it solves a lot of painpoints in Electron IPC communication. Plus I love the fact that you can swap out the environment from Electron main process to a server. Nice separation of concerns.

Regarding your Prisma migate integration. I saw you comment on the Prisma electron issue. How do you integrate this in your update workflow? You only call prisma migrate once an update is downloaded and installed? The whole update experience – at least for – me would be interesting for a blog post.

Thread Thread
awohletz profile image
Ayron Wohletz Author

Nice! You're inspiring me to write up with more detail how it works. I'll do that and submit a PR to your electron-prisma repo with migration/update code.

Thread Thread
awohletz profile image
Ayron Wohletz Author
Thread Thread
awohletz profile image
Ayron Wohletz Author

Welp, it has not been straightforward to get Prisma migrate running in the template github.com/florianbepunkt/electron... . The latest error I'm getting is:

(node:12172) UnhandledPromiseRejectionWarning: Error: Unable to load Node-API Library from node_modules\@prisma\engines\query_engine-windows.dll.node, Library may be corrupt
    at Object.loadEngine (C:\Programming\electron-prisma\release\app\node_modules\@prisma\client\runtime\index.js:36148:21)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async Object.instantiateLibrary (C:\Programming\electron-prisma\release\app\node_modules\@prisma\client\runtime\index.js:36103:5)
Enter fullscreen mode Exit fullscreen mode

Maybe this error has to do with how the boilerplate runs with ts-node, but not entirely sure ATM.

So I'm thinking of creating a separate template repo that has tRPC and Prisma migrate based on secure-electron-template, which I originally used for my app.

Thread Thread
florianbepunkt profile image
Florian Bischoff

Yes, this is due to the way the boilerplate is setup. Your architecture that clearly separates backend/frontend is better - haven‘t got around to update the boilerplate yet.

Collapse
florianbepunkt profile image
Florian Bischoff

Also I would be super interested in the migrations part. You include the Prisma CLI into the application bundle so you can perform migrations in shipped applications? Could you share some more details?

Collapse
awohletz profile image
Ayron Wohletz Author • Edited on

Yeah exactly, I include the Prisma CLI node module in my bundle so I can run migrate on app start. If I change my database schema, the app needs to migrate users' databases when they update the app.

I have this code in my Electron main.ts:


if (needsMigration) {
        try {
            const schemaPath = path.join(
                app.getAppPath().replace('app.asar', 'app.asar.unpacked'),
                'prisma',
                "schema.prisma"
            );

            // first create or migrate the database! If you were deploying prisma to a cloud service, this migrate deploy
            // command you would run as part of your CI/CD deployment. Since this is an electron app, it just needs
            // to run every time the production app is started. That way if the user updates AriNote and the schema has
            // changed, it will transparently migrate their DB.
            await runPrismaCommand({
                command: ["migrate", "deploy", "--schema", schemaPath],
                dbUrl
            });
Enter fullscreen mode Exit fullscreen mode

This Github thread has further details on how I use Prisma in Electron: github.com/prisma/prisma/issues/9613