In this article, we will explore an unconventional approach to organizing fullstack applications, which involves placing the business logic on the frontend side of the application.
A few years ago, I decided to dive into web programming after many years of developing desktop applications using languages such as Delphi, C++, Java, and C#. This new direction seemed very interesting, but one thing kept bothering me. While working in full-stack development and creating small applications using Vue and Node.js, I noticed that the application logic, which I was accustomed to in desktop development, was fragmented between the frontend and the backend. At first, I thought that this problem could be solved using Server-Side Rendering (SSR), but even with SSR, full integration was not achieved.
I started experimenting, aiming to build an architecture that would allow concentrating all business logic within the frontend part of the application. Let's see what I came up with using a simple example. Our demonstration application has primitive logic and can perform three tasks:
- Write string information to a file on the server.
- Read data from the file.
- Calculate the result of the expression 1 + 2.
We create a user interface (UI) in the file App.vue
, which contains three buttons and a field for displaying the result.
<!-- App.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import $ from './front-back/callBack';
import readData from './model/readData.shr';
import writeData from './model/writeData.shr';
import addUp from './model/addUp';
const data = ref('');
const onWrite = () => $(writeData)('Very important data.');
const onRead = async () => (data.value = await $(readData)());
const onAddUp = async () => (data.value = addUp(1, 2).result);
</script>
<template>
<div>{{ data }}</div>
<button @click="onWrite">put</button>
<button @click="onRead">get</button>
<button @click="onAddUp">add up</button>
</template>
Certainly, we can sum two numbers in the browser, so we implement this function in the addUp.ts
file and place it in the model
folder. However, the other two tasks can only be performed on the backend. Using the traditional approach, I would have to switch to the backend, create the appropriate controller, and perform other related actions. In this architecture, I simply create the files readData.ts
and writeData.ts
, add the .shr
postfix to their names, save them in the same model
folder, build the project, run it, and voilà — the application works!
<!-- readData.shr.ts -->
import type { FileOperations } from './IFileOperations.shr'
import { lib } from '@/front-back/lib'
export default async function readData() {
const fo = lib<FileOperations>('FileOperations')
const filePath = fo.join(fo.homedir(), 'example.txt')
try {
return await fo.readFile(filePath)
}
catch (error) {
return `Error writing to file "${filePath}"`
}
}
Let's now dive into the practical implementation. This architecture involves the following approaches:
rollup-plugin-copy: This plugin moves files with the .shr
postfix to the share
folder of the backend project based on the configuration in vite.config.ts
:
copy({
targets: [{ src: 'src/**/*.shr.ts', dest: '../backend/src/share'}]
});
Interface Wrapper around Node.js fs
Library: This avoids reference errors to non-existent libraries when placing the readData.ts
file in the frontend project.
export interface FileOperations {
homedir: () => string
join: (...paths: string[]) => string
writeFile: (file: string, data: string) => Promise<void>
readFile(filePath: string): Promise<string>
}
Dependency Injection on the Backend: The interface wrapper is bound to the implementation via Dependency Injection.
Calling the Function in App.vue
: If you closely examined the code of App.vue
, you would notice that the function is called in a somewhat unusual way, $(readData)()
. On the frontend, a function named $
is implemented, which takes functions with the .shr
postfix intended for backend execution. It gets type definitions, the function name, sends a request to the backend, receives a response, and parses the data, converting it to a known type.
type FN = (...args: any) => any
const $ = <F extends FN>(fn: F) => {
return async (...args: Parameters<F>) => {
const result = (await postMethod({ name: fn.name, args }))
if (result.log) console.log(result.log)
if (!result.isOk) throw new Error('back error: ' + result.err)
return result.result as Awaited<ReturnType<F>>
}
}
getResult Function on the Backend: This function takes the name received from the frontend and its arguments, executes the forwarded frontend function during the build, resolves its dependencies, and returns the result to the frontend.
const getResult = async (data:DataFromFront):Promise<DataFromBack> => {
const method=(await import(`../share/${data.name}.shr.js`)).default;
if (!method) throw new Error(`Method ${data.name} not found`);
try {
const args = data.args as unknown as Parameters<typeof method>;
const result = await method(...args);
return { result, isOk: true, log: backendLogger.get() };
}
catch (err) {
return {
err: err instanceof Error ? err.message : (err as string),
isOk: false,
log: backendLogger.get(),
};
}
};
Thus, the proposed architecture allows effectively organizing the business logic by concentrating it on the frontend side. This approach simplifies development and speeds up the build process by eliminating the need for frequent switching between the frontend and backend. I hope this example proves useful for those striving for a tighter integration of their application's logic.
It is worth noting that this approach has its drawbacks:
- The need to create interface wrappers around backend libraries. Although this can also be considered an advantage as it enhances independence from their implementations by adhering to the principle of "program to interfaces, not implementations".
- It is oriented towards smaller projects.
You can find more details on the implementation in my GitHub repository:
https://github.com/vorron/frontend
https://github.com/vorron/backend
Top comments (0)