DEV Community

Alexander Voronkov
Alexander Voronkov

Posted on

Organizing Fullstack Applications with Frontend Business Logic

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>
Enter fullscreen mode Exit fullscreen mode

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}"`
  }
}
Enter fullscreen mode Exit fullscreen mode

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'}]
});
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>>
  }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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".
  2. 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)