Three-Layered Architecture for Front-End

The three-layered architecture: presentation, business and data layers

In this article I'll go through the basic principles of a software engineering architecture that is the foundation of modern frontend development.

The concepts behind are simple, yet is easy to mess things up, and the results are often a frontend codebase that is hard to maintain, feels like a mess and does not scale.

But let's start from the beginning.

What is a Three-Layered Architecture?

The Three-Layered Architecture is a long-standing pattern in software engineering, traditionally used in full client-server systems but equally applicable to modern frontend applications.

At its core, it separates an application into three distinct layers:

  • Presentation layer - The UI, what the user sees and interacts with

  • Business Logic layer - How the application behaves

  • Data Access layer - How data is fetched, stored, and managed

What problems does it solve?

In frontend applications, especially as they grow, concerns tend to blur: components fetch data directly, business rules leak into UI logic, and API handling becomes inconsistent.

The result is a codebase that works… until it becomes difficult to change.

The 3-layered approach introduces clear boundaries and answers directly to questions like:

  • "Where should I fetch data for this page or component?"

  • "Isn't this component too big and doing too many things at once? How should I refactor it?"

The idea is that the UI doesn’t care where data comes from, the business logic doesn’t care how it’s displayed, and data access shouldn’t care how it’s used.

This decoupling makes systems easier to: scale, test, refactor and, generally speaking, reason about.

How components should not look like

Let's start with a a bad example: how a component that is in charge of rendering a user profile does too many things at once, mixing presentation, logic and data fetching.

As can be seen, the mistake is letting the component handle directly API calls, data transformations and business logic (loading, errors and parsing):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
interface ParseUserProps {
  userData: UserData,
}

const UserProfile = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
  
  // parsing logic should be moved in a hook
  const parseUser = useMemo(({ userData }: ParseUserProps): UserProfile => {
    return {
      firstName: userData.firstName,
      lastName: userData.lastName,
      email: userData.email,
    }
  }, [user]);
  
  useEffect(() => {
    // all code related to data fetching should be extracted
    async function fetchData() {
      // as can be seen, this component loading state is mixed with
      // data fetch logic; same is true for errors handling and user data
      setIsLoading(true)
      setError(null)

      try {
        const response = await fetch(`/api/user/${id}`)

        if (response.ok) {
          throw new Error('Resonse failed with status ', response.status);
        }

        const user = await response.json();

        setUserProfile(parseUser(user))
      } catch (error) {
        setError(error)
      } finally {
        setIsLoading(false)
      }
    }

    fetchData();
  }, []);

  if (isLoading) return <Spinner />

  return <UserView user={user} />
}

The presentation layer

The presentation layer is responsible for rendering the UI and handling user interactions.

In React or Vue applications, this typically includes:

  • components

  • templates / JSX

  • styling

  • event handling

And stop.

Logic should ideally be "used" or "consumed" through hooks, composables, utility functions, or passed by props.

Responsibilities

  • Display data

  • Capture user input

  • Trigger actions (e.g. button clicks, form submissions)

What it should look like

A clean presentation layer is declarative, predictable and just keeps it simple. There should be not be any API logic, no complex transformations nor data fetch itself.

Example:

1
2
3
4
5
6
7
8
9
const UserProfile = () => {
  // user parsing and loading logic is handled by hooks
  // data fetch logic isn't even visible here
  const { user, isLoading } = useUserProfile()

  if (isLoading) return <Spinner />

  return <UserView user={user} />
}

Business Logic layer

The business logic layer is where the actual behavior of your application lives. It is the most important - and most often neglected - layer in frontend applications.

To enhance reusability, this layer is typically implemented as a set of custom hooks or composables.

Responsibilities

  • Drive application functionalities

  • Apply algorithms and data transformation

  • Coordinate actions

  • Encapsulate use cases

Some examples are:

  • filtering and sorting data

  • combining multiple API responses

  • handling permissions

  • orchestrating workflows

Without a clear business layer logic spreads across components, rules are duplicated and behavior becomes inconsistent.

What it should look like

In modern frontend stacks, this layer often takes the form of:

  • custom hooks (React)

  • composables (Vue)

  • service modules

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const useUserProfile = () => {
  // user, loading and error states
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  // parse function encapsulated - direct concern of this hook
  const parseUser = useMemo(
    ({ user }: { user: UserData }): UserProfile => {
      return {
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
      }
  }, [user]);
  
  // fetch logic handled here, but no direct contact with the API
  useEffect(() => {  
    const fetchUserData = async () => {  
      try {
        setIsLoading(true);
        const data = await fetchUser();
        setUser(parseUser(data));
      } catch (error) {  
        setError(error);
      } finally {  
        setLoading(false);  
      }  
    };  
  
    fetchData();  
  }, []);
  
  // returning only the data we need, no logic
  return { user, error, isLoading }
}

export default useUserProfile;

Data Access layer

The data access layer is responsible for interacting with external systems.

In short terms, it means fetching data from APIs, set/load from local storage and or from third party services.

Responsibilities

  • Fetch data

  • Send data

  • Handle low-level concerns (headers, endpoints, errors)

It should not contain business logic, shape data for UI needs and, generally speaking, know anything about components.

What it should look like

Let's see it an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const fetchUser = async (id: string) => {
  try {
    // the hook only takes care of interfacing with the API
    const response = await fetch(`/api/user/${id}`)
		
    if (response.ok) {
      throw new Error('Resonse failed with status ', response.status)
    }

    return await response.json();	
  } catch (error) {
    // error re-thrown to be handled from caller
    throw new Error(`Failed to fetch data: ${error.message}`)
  }
}

Conclusions

What I do like the most of software engineering is in how many ways the same code can be written to achieve similar results, but this also means we have plenty of ways to mess things up.

As development frameworks like Vue and React does not enforce a particular architecture, it's all on us to select the most appropriate in order to promote code that scales and it's easy to maintain in the long run.

This article explores the use of layered architecture to create highly maintainable React components and what are the common mistakes we should avoid.