Backend Dev – What to Learn in a Framework

August 6, 20267 min readUpdated 8/20/2026

Spring Boot has thousands of classes. Django, Rails, Laravel, ASP.NET and Express are all large in the same way. You will use a small and surprisingly stable part of any of them, and it is the same part in all of them — which is why moving between frameworks is much easier than learning your first one.

This post is that part: seven things to be able to do, in the order you need them. Every example is Spring Boot 4.1, but check the list against whatever you are using and the mapping will be obvious.

What a framework is actually for

A library is code you call. A framework is code that calls you. You write a method, annotate it, and something you did not write decides when to run it, what to pass it, and what to do with what it returns.

That inversion is the whole idea, and everything below is a consequence of it. It also explains the frustration of the first few weeks: when your code does not run, there is no call site to put a breakpoint on. Learning where the framework's decision points are is learning the framework.

1. Start a project and add a dependency

For Spring Boot, start.spring.io generates the project. What you actually need to understand is the build file, because that is where every "it worked on my machine" argument ends up.

Know these three things:

  • A starter pulls in a stack, not a library. Adding spring-boot-starter-data-jpa brings Hibernate, a connection pool, the transaction manager and their configuration. One line, thirty jars — that is intended.
  • Versions come from a parent or a BOM. That is why most dependencies in a Boot project have no <version>: the parent has already chosen a set that works together. Overriding one version by hand is how you get a runtime NoSuchMethodError.
  • Transitive dependencies exist. You depend on things you never named. When two of them want different versions of a third, you get to arbitrate.

2. Configuration and profiles

Your app runs on your laptop, in a test pipeline, in staging and in production. The code is identical; the database URL, the credentials and the log level are not.

The rule: one artifact, many configurations. Never build a separate jar per environment — then the thing you tested is not the thing you shipped.

In Boot, settings live in application.properties, with application-<profile>.properties layered on top when that profile is active:

# application.properties — the defaults everyone gets
server.port=8085
spring.jpa.hibernate.ddl-auto=validate
pizza.pricing.tax-rate=0.085
pizza.pricing.delivery-fee=3.99

# Stripe keys are NEVER committed. Put real test keys in
# application-local.properties (gitignored) or set the env vars below.
pizza.stripe.secret-key=${STRIPE_SECRET_KEY:}

Two details in that snippet are worth copying. ${STRIPE_SECRET_KEY:} reads an environment variable and falls back to empty — that is how a secret gets in without ever being in git. And ddl-auto=validate makes the app refuse to start if the code and the database have drifted apart, which is a loud failure at boot instead of a silent mismatch found in production.

Then bind the settings into typed objects rather than reading them one string at a time. This is the single highest-value configuration habit:

@Validated
@ConfigurationProperties(prefix = "pizza")
public record PizzaProperties(
        @Valid Pricing pricing,
        @Valid Jwt jwt,
        @Valid Stripe stripe,
        @Valid Cors cors,
        @Valid Storage storage,
        @Valid Mail mail) {

    public record Pricing(
            @DecimalMin("0.0") @DecimalMax("1.0") BigDecimal taxRate,
            @DecimalMin("0.0") BigDecimal deliveryFee) {}

    public record Jwt(@NotBlank @Size(min = 32) String secret, @Positive long expirationMinutes) {}
}

Why it is worth the class: @Validated checks those constraints while the application is starting. A missing or nonsensical value fails the boot with a message naming the property. The version this replaced read nine separate values by hand, and a missing JWT secret became a null field — so the first person to log in got a NullPointerException from deep inside a JWT library. Two of those values were also declared in two different classes, with nothing checking that they still agreed.

3. Dependency injection

Your service needs a repository. Rather than constructing one, you declare that you need one and the framework supplies it.

@Service
@RequiredArgsConstructor
public class JwtService {

    private final PizzaProperties properties;   // supplied at startup
    ...
}

Lombok's @RequiredArgsConstructor generates the constructor from the final fields; Spring sees one constructor and injects into it.

This is not fashion. It buys three concrete things:

  • You can test the class. Pass a fake in the constructor. If the class built its own dependency with new, there would be no seam.
  • Wiring lives in one place instead of being scattered across every call site.
  • Lifecycles are managed. One connection pool, shared, created once — not one per object that wanted a connection.

Prefer constructor injection over field injection. A final field set by a constructor cannot be null and cannot be reassigned, and the constructor signature makes it obvious when a class has grown eight dependencies and should be split. The full case is in Spring Boot – Dependency Injection.

4. The path from URL to your method

You need a mental model of what happens between the request arriving and your code running, because that is where you will be debugging.

  1. The embedded server accepts the connection and hands it to a thread.
  2. Filters run — CORS, then security. A request can be rejected here and never reach you.
  3. The framework matches the method and path to one of your methods.
  4. The JSON body is deserialised into an object and validated.
  5. Your method runs.
  6. The returned object is serialised back to JSON with a status code.
  7. If anything threw, an exception handler turns it into an error response.
@Tag(name = "Products", description = "Menu browsing (public)")
@RequestMapping("/api/products")
@RestController
@Slf4j
public class ProductRestController {

    @Autowired
    private ProductService productService;

    @Operation(summary = "List the active menu, optionally filtered by type")
    @GetMapping
    public ResponseEntity<List<ProductDTO>> getProducts(@RequestParam(required = false) ProductType type) {
        log.info("GET /api/products type={}", type);
        List<ProductDTO> products = type == null ? productService.getMenu() : productService.getByType(type);
        return new ResponseEntity<>(products, OK);
    }
}

Nothing in that method opens a socket, parses JSON or writes a status line. That is step 1, 4 and 6 done for you. Learning where steps 2 and 7 are configured is most of what "learning the framework" means in practice.

5. The three layers, and why they are not bureaucracy

LayerJobMust not
ControllerSpeak HTTP. Bind the request, return a status. Contain business rules
ServiceThe actual rules. Owns the transaction boundary. Know that HTTP exists
Repository / DAOTalk to the database. Make decisions

The test for whether you have this right: could a scheduled job call your service? If the rules are in the controller, no — you would have to duplicate them, and the copy will drift. In the demo app a message listener, a scheduled job and three controllers all call the same services, which is only possible because none of those services knows what a request is.

6. Cross-cutting behaviour is annotations — and they are all the same mechanism

Transactions, caching, security, async, retries, timing. You do not write these; you annotate a method and the framework wraps it.

@Override
@Cacheable(value = CacheConfig.MENU_CACHE, keyGenerator = "methodAwareKeyGenerator")
@Transactional(readOnly = true)
public List<ProductDTO> getMenu() {
    log.debug("Getting the active menu");
    return mapper.mapProductsToProductDTOs(productDAO.findActiveMenu());
}

Two annotations replace a cache lookup, a cache write, a transaction begin, a commit and a rollback handler.

7. The proxy rule — learn this one properly

Spring implements all of the above by wrapping your bean in a proxy. Calls from other beans go through the proxy and get the behaviour. A call from one method of a class to another method of the same class does not — it goes straight to the target and skips the wrapper entirely.

public void a() { b(); }   // b() is NOT advised - internal call, no proxy involved
public void b() { ... }    // advised only when some OTHER bean calls it

Which means, silently and with no error at all:

  • a @Transactional method called from a sibling runs with no transaction;
  • a @Cacheable method called internally always hits the database;
  • an @Async method called internally runs on the caller's thread;
  • a @PreAuthorize method called internally is not checked at all.

If an annotation appears to be doing nothing, look for a self-invocation before you look at anything else. The fix is to move the annotated method onto another bean — where it usually belonged anyway.

How to tell a framework problem from your own bug

Almost always it is your bug. Before assuming otherwise:

  • Read the whole stack trace, especially the last "Caused by". The real error is at the bottom.
  • Check the startup log. Boot reports which port it bound, which profiles are active and which auto-configurations ran. Half of "it does not work" is the wrong profile.
  • Check for a self-invocation — see above.
  • Read the reference documentation, not a 2018 blog post. Spring Security 7 removed the configuration style that most search results still show, so those examples do not even compile.

What to remember

  • A framework calls you. Learning it means learning where its decision points are.
  • One artifact, many configurations. Secrets come from the environment, never from git.
  • Bind configuration into validated, typed objects so a bad value fails at startup.
  • Constructor injection, so the class stays testable and its dependencies stay visible.
  • Controller speaks HTTP, service holds the rules, repository talks to the database. Ask: could a scheduled job call this service?
  • Cross-cutting behaviour is annotations, all built on proxies — so self-invocation silently skips every one of them.

Next: HTTP and API design — the contract you hand to everyone else.