DEV Community

loading...
Cover image for Full-Stack in Four Files

Full-Stack in Four Files

Ryan Lynch (he/him)
I solve computer problems as a dev, human problems as a teacher
・5 min read

Learning to develop a full-stack application today is hard! There are tools that make it easier, but usually at the cost of scalability or flexibility. It would be wonderful if instead the enthusiasm of learning could translate more directly to a scalable full-stack application. Why can't we abstract away more of the boilerplate but still build an application that can scale? I think we can!

What follows is an application built with four files, and a description of its scaffolding framework. The framework described has yet to be fully realized β€” but many of the needed pieces already exist β€” and are linked below for your immediate use. The idea for this framework is not to solve every edge case of application development. Instead it's to provide a simplified foundation for learning and building full-stack applications.

To that end, how simple can we make it? What if you could define a database, types, and access methods with a single readable schema file? What if building and accessing your server API was as simple as importing and exporting functions? What's 'missing' in these code examples is a turn-key solution that scripts these tools with the following files to realize a working application. I'm sharing my ideas to gather some better ones, so please share yours!

File 1: The Database

I started learning about databases in web applications over a decade ago, and in that time so much has advanced, and yet it feels like the difficulty of creating and managing relational databases has remained the same. At the end of the day relational databases are still a powerful way to explain connected items of data, and for most applications they would be a good choice.

There are innovative groups however working on systems based around GraphQL, and they make it possible to represent a database in code quite succinctly. With services like Fauna or Prisma, it's possible to define a stateful relational database with a single GraphQL schema file. Pretty cool! Below is one I adapted from FaunaDB for this example!

schema.gql

type Counter {
  title: String!
  count: Number!
}

type Query {
  allCounters [Counter!]
}

Enter fullscreen mode Exit fullscreen mode

From that, Fauna can intuit an internal database structure, as well as a GraphQL api to access and modify it. These schema files can also be used to generate typescript types for the types in the schema. Why are these typescript types are useful? With Prisma for example, this schema to type conversion allows for the generation of a complete typed client, and so one file to generate your database and access layer is already possible!

File 2: The Server

What do you need for a server these days? It used to be that you needed a large scaffold of configuration to contain your remote procedures. With the advent of Function as a Service (FaaS), for the majority of applications you just need to define the remote procedures, or functions. So that's all we should have to do here to define a stateless server:

server.ts

import { Get, Post, Patch } from 'missing'

import { db } from './schema.gql'

export const getAllCounters: Get = async () => db.allCounters()

export const createCounter: Post = async (
  title: string,
  count: number = 0,
) =>
  db.createCounter({
    title,
    count,
  })

export const updateCounter: Patch = async (
  id: string,
  count: number,
) =>
  db.updateCounter({
    id,
    count,
  })
Enter fullscreen mode Exit fullscreen mode

These asynchronous functions are light on logic, in this case just reforming arguments to pass along to the database methods. You could imagine these functions handling much more however. The types from the 'missing' library provide additional information about how the function should exist as a server endpoint. I've put the types as HTTP verbs here, but they could indicate other "metadata" about the functions and inform deeper integrations with cloud systems. With this extra type information, these functions could be wrapped and input into any number of cloud specific FaaS offerings (AWS Lambda, Google Cloud Functions, ect.), or a cloud agnostic system like Kubeless, Fission, or OpenFaaS.

The real benefit here however would be to abstract away the concern of where the function is run and how to invoke it remotely, and rather just import it on the frontend. So let's cross over from the server now, and start looking at the client application!

File 3: Components

Ah, the frontend! My bailiwick. In fact I think the whole reason I want to build this framework is to make it easier to set the foundation and get to a stateful interactive frontend more quickly! There's been much innovation here over the last decade, and there are many flavors of frontend frameworks to make your life more...well, flavorful.

While fetch is an improvement over XMLHttpRequest, I think we can do a step better here since we are also deploying the server functions. If the framework also invokes our server functions β€” then we should be able to abstract that call based on environment β€” and present our frontend components with simplified imported functions that have the same signature as our server functions, as we see in this React example:

component.tsx

import React from 'react'

import {
  getAllCounters,
  createCounter,
  updateCounter,
} from './server'

import { Counter } from './schema.gql'

const useCounters = () => {
  const [loaded, updateLoaded] = React.useState<boolean>(false)
  const [counters, updateCounters] = React.useState<Counter[]>([])

  React.useEffect(() => {
    if (!loaded) {
      getAllCounters().then(counters => {
        updateCounters(counters)
        updateLoaded(true)
      })
    }
  }, [loaded])

  return {
    loaded, 
    counters, 
    triggerReload: () => updateLoaded(false)
  }
}

export const Counters: React.FunctionComponent = () => {
  const nameRef = React.useRef<HTMLInputElement>(null)
  const {loaded, counters, triggerReload} = useCounters()

  const renderCounters = () => {
    if (!loaded) {
      return <li>loading</li>
    } else if (!counters.length) {
      return <li>no counters</li>
    }
    return (
      <>
        {counters.map(({ count, id, title }) => (
          <li
            key={id}
            onClick={async () => {
              await updateCounter(id, count + 1)
              triggerReload()
            }}
          >
            {title} {count}
          </li>
        ))}
      </>
    )
  }

  return (
    <ul>
      <li>
        <input ref={nameRef} title="new counter name" />
        <button
          onClick={async () => {
            await createCounter(nameRef.current.value)
            triggerReload()
          }}
        >
          new counter
        </button>
      </li>
      {renderCounters()}
    <ul/>
  )
}

Enter fullscreen mode Exit fullscreen mode

This example uses a React component, but these functions we've imported from ./server might be integrated into components from other frameworks. Or we could skip the framework based components all together and do something with only the facilities of the browser. The real point is...you shouldn't need to understand AJAX to build something!

File 4: Frontend

Finally we need to bring these components (or simply our client scripts) together, and we could express that as an HTML page, something Parcel already supports well. Since this example uses React, I'm instead going to use MDX, which can represent our component markup and content in a simple markdown based format:

index.mdx

import { Counters } from './components'

# Check out these counters!

<Counters />
Enter fullscreen mode Exit fullscreen mode

Each frontend entry file would map to an HTML file and a set of bundled resources, which could be deployed in a number of ways. For example, with OpenFaaS these bundles could map to scalable NGINX instances or some other static service, that are deployed in the same way as the server functions. The idea here would be to provide as much the same experience locally as in production, while still allowing for things to scale.

Conclusion

So the conclusion is, what you see is what I have. I've put a lot of thought into this, and this post is me putting my experiment and thoughts out to the world, because what I've described is a framework I'd like to see in it.

Scalable, flexible, but most importantly more simple! Is this simpler? You tell me! I would love to hear if this is a tool you'd like to see in the world, especially if you have ideas for additions or modifications. Thanks for reading, and thanks for your thoughts! πŸ”°

Discussion (0)