Markus Oberlehner

Understanding 'Magic' in Programming Frameworks: Examples, Pros and Cons


What do we mean when we talk about “magic” in programming? I recently came up with a simple definition while watching Ricky Gervais explain the difference between believing in science and believing in religion:

”[…] if we take something like any fiction, and any holy book […], and destroyed it, okay. In 1,000 years’ time, that wouldn’t come back just as it was. Whereas if we took every science book and every fact and destroyed them all, in 1,000 years, they’d all be back because all the same tests would yield the same results.”
Ricky Gervais and Stephen go head-to-head on religion

Ricky Gervais makes an excellent point: if we erased all the knowledge we have right now and catapulted ourselves back into the Stone Age, both religion and science would likely re-emerge. Yet while the God(s) worshipped by the newly formed religion(s) might look completely different, science would arrive at mostly the same conclusions.

Now, for the definition of “magic” in programming, let’s paraphrase his statement:

If we took all the documentation of a framework relying heavily on magic and destroyed it, recreating a particular application using that framework would become impossible (without looking at the framework’s source code). Whereas if we took all the documentation of a framework that emphasizes explicitness, transparency, and predictability, building applications based on it would remain possible indefinitely.

We can extend the analogy even further: if you believe in a particular magic-reliant framework’s way of doing things, developers from the in-group will have an easy time working and cooperating within the boundaries of the framework, helping them achieve results quickly. Yet an environment like that is not very welcoming to newcomers—especially those who don’t plan to stay long-term but want or need to navigate multiple frameworks.

On the other hand, frameworks based on the principles of explicitness and transparency are more welcoming to newcomers because there are fewer things you have to know or look up to be productive. Yet, more often than not, this explicitness also comes with the cost of having to do more work to achieve similar results.

Examples of Magic and Explicitness

Let’s move from the abstract into the concrete by taking a closer look at more or less magical ways of doing things in current frameworks.

Next.js Server Actions vs. TanStack Server Functions

In the following Next.js Server Action, some magical things are going on:

'use server'

import * as userRepository from '../repositories/user-repository';
 
export const createUser = async (formData: FormData) => {
  const name = formData.get('name')
  const age = formData.get('age')

  if (!name || !age) {
    throw new Error('Name and age are required')
  }
 
  return userRepository.create({
    name: name.toString(),
    age: parseInt(age.toString(), 10),
  });
};

The 'use server' directive is a pseudo-standard in the React and Next.js world. Without prior knowledge about this directive, it’s utterly opaque that the createUser() function is not just any regular function but a Server Action.

Now let’s compare that with a non-magical variant of a similar concept in TanStack Start:

// Exports are part of the public API of a package;
// we don't need to look them up in the documentation.
import { createServerFn } from '@tanstack/react-start';
import * as userRepository from '../repositories/user-repository';

// Function parameters and object properties are also
// part of the public API and therefore self-documenting.
export const createUser = createServerFn({ method: 'POST' })
  .validator((formData) => {
    if (!(formData instanceof FormData)) {
      throw new Error('Invalid form data')
    }
    const name = formData.get('name')
    const age = formData.get('age')

    if (!name || !age) {
      throw new Error('Name and age are required')
    }

    return {
      name: name.toString(),
      age: parseInt(age.toString(), 10),
    }
  })
  .handler(async ({ data }) => {
    return userRepository.create({ data });
  })

TanStack Start provides us with a createServerFn() function to explicitly create a special kind of function that is intended to run only on the server side and must adhere to a certain API. Using createServerFn() makes it completely transparent how this function is special and enables us to get a fully typed server function that clearly shows us how we can use it—without requiring any prior knowledge about this framework.

Magic Exports vs. Explicit Properties

In some frameworks (the following example is from Next.js again), we can change the behavior of components or modules by exporting a constant with a particular value:

// Next.js magic export example
export const dynamic = 'force-dynamic';

export default function Page() {
  // page implementation
}

Although this might be somewhat self-documenting if we see it in our codebase, there’s no straightforward way to discover this arcane magic if we don’t use it anywhere yet, and we need the functionality for a new page. However, if this were a property of a function, we could discover it via the public API:

// Explicit API example (hypothetical)
export default definePage({
  dynamic: 'force-dynamic',
  component: () => {
    // page implementation
  },
});

Spring Boot Component Auto-Discovery vs. Explicit Wiring in Javalin

Spring Boot’s “magical” approach to reducing boilerplate code is perfectly exemplified by its component auto-discovery mechanism. This feature allows developers to create functional endpoints with minimal configuration, letting the framework handle the heavy lifting behind the scenes:

// No explicit configuration needed. Spring Boot automatically:
// 1. Discovers this controller through component scanning
// 2. Maps it to the "/hello" endpoint
// 3. Handles JSON serialization/deserialization
@RestController
public class HelloController {

    // Automatically injects this service without explicit wiring
    @Autowired
    private HelloService helloService;

    @GetMapping("/hello")
    public String sayHello(@RequestParam(defaultValue = "World") String name) {
        return helloService.generateGreeting(name);
    }
}

Now, let’s contrast this with a more explicit approach using the lightweight Javalin framework:

public static void main(String[] args) {
    Javalin app = Javalin.create().start(8080);
    HelloService helloService = new HelloService("Hello, %s!");
    HelloController controller = new HelloController(helloService);
    
    app.get("/hello", ctx -> {
        String name = ctx.queryParam("name", "World");
        ctx.result(controller.sayHello(name));
    });
}

This explicit style makes the application flow immediately apparent to any developer reading the code—even if they aren’t familiar with the specifics of the framework.

The Pros and Cons of Framework Magic

The following might sound like a hypothetical scenario, but it isn’t! What if, 10 years from now, Next.js is no longer actively maintained? Imagine Vercel goes bankrupt, the website goes down, and the GitHub repository is deleted. Sure, the likelihood of all this happening to Next.js isn’t huge, but it’s not zero either! Situations like these have occurred in the past. The only reason not many people think about this is because the software industry is still relatively young. But I know from personal experience that there are plenty of (critical) software systems out there where maintenance is a pain—and one reason is that it’s hard to find documentation matching the specific version of a framework or library.

But there are other downsides to magic, too. The older a software project gets, the fewer people you’ll find who are familiar with a particular framework (or even a specific version of it). So, if you have to maintain an older project on the side while mainly working on a new project, your developers must be able to quickly understand what’s going on in the legacy project. The more magic involved, the harder this becomes for people who are unfamiliar with the project. And if the only thing you do is maintenance work, you’ll never become comfortable with the intricacies of the framework and will constantly need to refer to the documentation.

On the other hand, the more heavy lifting a framework does for us and the more it hides complicated logic, the faster we can build even complex applications. Magic is great for people who are highly familiar with a framework because they know exactly what the magic does and how it makes their lives easier. For them, working with the framework feels like working alongside an old friend, where not everything needs to be explicitly stated all the time.

Wrapping It Up

I refrain from clearly labeling one approach as better or worse. It’s all about trade-offs, and although the more scientifically minded among us don’t like to hear it, most things are ultimately a matter of personal taste.

Recently, I’ve been switching between different frameworks more frequently, so currently, I tend to prefer less magic. Yet I’ve also been on the other side, enjoying the productivity boost that comes with a highly magical framework.

But we don’t have to settle the debate about whether this or that approach is better. We can accept that both have their pros and cons—and appreciate that.