Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 100 / 100

100 days of Java and Spring Boot: the recap in practice

The lessons that matter most from the whole series.

Expona Admin8 min read

A hundred days of lessons that stick

After 100 days of building with Java and Spring Boot, the question isn't what we learned—it's what actually matters when the project moves from laptop to production. 100 days of Java and Spring Boot: the recap in practice is about filtering signal from noise, understanding which features genuinely improve our systems, and knowing when to push back on defaults. We've covered syntax, frameworks, patterns, and deployment strategies, but the real wins come from knowing why each piece exists and when to apply it.

Modern Java is pleasant — and it's worth using

Java in 2024 looks nothing like Java in 2014. Records, sealed types, pattern matching, and virtual threads have made the language feel less ceremonial and more expressive. These aren't cosmetic changes—they reduce boilerplate that clouds intent and introduce capabilities that make concurrent systems feasible at scale.

Records eliminate the classic data carrier class. Instead of writing a 20-line class with constructors, accessors, equals, and hashCode, we write:

java
public record Order(Long id, String customerId, BigDecimal total, LocalDateTime createdAt) {}

That's immutable, serializable, and automatically implements sensible equality. In a typical Spring Boot service handling domain objects or API responses, records cut noise and force immutability by default—a small win that compounds.

Sealed types let us constrain inheritance hierarchies. When we're building a payment processing pipeline and know we have exactly five payment methods, we can express that constraint:

java
public sealed class PaymentMethod permits CreditCard, BankTransfer, PayPal, ApplePay, GooglePay {
    // shared logic
}

The compiler ensures we don't accidentally add a sixth case and miss it in our switch statement. That's not decoration—that's compiler-enforced correctness.

Pattern matching turns instanceof checks into readable, compositional expressions. The old way required casting and nested ifs. Now we write:

java
if (response instanceof ErrorResponse err && err.statusCode() >= 500) {
    logger.error("Server error: {}", err.message());
}

Virtual threads (Project Loom, now in the platform) are the game-changer. Traditional threads are heavyweight. Virtual threads let us write one thread per request without the memory and context-switch overhead. For I/O-bound services—which is most of what we build—this collapses the complexity of async frameworks. We can write synchronous code, use thread-local storage naturally, and handle thousands of concurrent connections with minimal tuning.

The practical takeaway: upgrade to Java 21 or later. Adopt records for domain and API objects. Use sealed types in your domain layer. And watch for opportunities to use virtual threads in new projects, especially those with high request concurrency.

Spring Boot is about defaults — and knowing when to override them

Spring Boot's core strength is auto-configuration. Drop a PostgreSQL driver on the classpath, add spring-boot-starter-data-jpa, and you get a DataSource and JpaTransactionManager pre-wired. Most of the time this saves hours. Sometimes it causes subtle bugs or performance problems because we don't realize what's happening under the hood.

Understanding auto-configuration means reading the condition annotations. When does Flyway run? When does Jackson serialize dates as timestamps versus strings? What's the default thread pool size for async execution?

Run your application with debug logging to see what autoconfigures and why:

java
java -jar app.jar --debug | grep "Positive match"

This shows which AutoConfiguration classes activated and which were skipped. For a typical Spring Boot service, you'll see entries like DataSourceAutoConfiguration, TransactionAutoConfiguration, JpaRepositoriesAutoConfiguration, and others. Each one is setting defaults based on assumptions: single datasource, local transactions, standard persistence patterns.

Override with intent, not by accident. If the default thread pool for async methods is 8 threads and your workload needs 32, don't just add a magic number to properties. Instead, configure an Executor bean and explain why:

java
@Configuration
public class AsyncConfig {
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(32);
        executor.setMaxPoolSize(64);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-worker-");
        executor.initialize();
        return executor;
    }
}

Now someone reading this code—including future you—understands the choice. That's better than discovering "why is async slow?" three months later.

Same principle applies to data access, caching, security, and serialization. Read the starter documentation, understand what it configures, measure baseline behavior, and only override when you have evidence and context.

Data access is where performance lives

Most performance problems in Spring Boot applications come from the database layer, and most of those come from queries. We see three patterns repeatedly: N+1 select problems, unbounded result sets, and transactions held open too long.

N+1 selects happen when ORM lazy-loads associations. A loop over 100 orders, accessing order.customer() each time, runs 101 queries instead of 1. A JOIN FETCH solves this for direct access, but causes Cartesian products when you have multiple collection associations. Then you reach for DTO projections or explicit batch loading.

The right approach depends on your access pattern. If you always need customer details with orders, use a DTO projection:

java
public interface OrderDTO {
    Long getId();
    String getCustomerName();
    BigDecimal getTotal();
}

public interface OrderRepository extends JpaRepository {
    List findAllProjected();
}

If you need full entities sometimes and lean objects other times, use named queries or explicit fetch strategies. The key is measuring. Enable SQL logging in dev, use tools like p6spy or Spring Data's query insights, and watch actual query counts against expected behavior.

Unbounded result sets are less obvious but equally harmful. A query that returns 50,000 rows without pagination feels fine on 100 rows, then takes 10 seconds and consumes gigabytes of heap. Enforce pagination at the repository level using Spring Data's Pageable:

java
Page findByStatus(OrderStatus status, Pageable pageable);

Make pagination mandatory in your API contracts. Default to size=20, limit max to 100, and require explicit offset or cursor-based handling for large datasets.

Transactions held open too long block other processes and cause connection pool exhaustion. A transaction should cover only the data operations it actually needs. Don't open a transaction, call three external APIs, and close it. Separate concerns:

java
@Service
public class OrderService {
    @Transactional
    public Order createOrder(CreateOrderRequest req) {
        // validate and save to database
        Order order = repository.save(new Order(...));
        return order;
    }
    
    public void notifyCustomer(Order order) {
        // call email API outside transaction
        emailService.send(order.customerId(), "Order confirmed");
    }
}

Keep transactions short. Mark read-only where applicable. Use connection pooling tuning (HikariCP is the default and excellent) and monitor active connections in production.

Production is the real test — security, observability and safe deploys

A feature that works in dev often breaks in prod because of network latency, auth context, or observability gaps. Spring Boot makes this easier than it should be, but we still need discipline.

Security must be explicit. Spring Security defaults to "deny all" if you don't configure otherwise—that's good—but it's easy to accidentally permit endpoints meant to be internal. Use method-level security annotations or a well-structured SecurityFilterChain. Test your security rules:

java
@SpringBootTest
public class SecurityTest {
    @Autowired
    private MockMvc mvc;
    
    @Test
    public void publicEndpointIsAccessible() throws Exception {
        mvc.perform(get("/health"))
            .andExpect(status().isOk());
    }
    
    @Test
    public void adminEndpointRequiresRole() throws Exception {
        mvc.perform(get("/admin/users"))
            .andExpect(status().isUnauthorized());
    }
}

Verify that secrets (API keys, database passwords) never appear in logs or error messages. Use environment variables or a secrets manager, never application.properties checked into source control.

Observability is how we know what's actually happening. Spring Boot Actuator exposes metrics out of the box. Micrometer collects them. Push those metrics to Prometheus or CloudWatch and set up alerts on latency, error rate, and resource usage. Add structured logging with a correlation ID so you can trace a single request across services:

java
@Component
public class LoggingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
        String traceId = UUID.randomUUID().toString();
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}

Then every log from that request includes the traceId. Operators can search logs by trace and follow the request through every system it touches.

Graceful deploys mean rolling updates without dropped connections. Implement health checks that report readiness separately from liveness. A service can be alive but not ready to accept traffic (e.g., during startup, warming up caches). Use Spring Boot's health indicator framework:

java
@Component
public class CacheWarmupHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        if (cacheService.isWarmupComplete()) {
            return Health.up().build();
        }
        return Health.outOfService().withDetail("reason", "Cache warmup in progress").build();
    }
}

Your orchestration platform (Kubernetes, ECS, etc.) waits for readiness before routing traffic. When deploying, drain connections gracefully: set a shutdown timeout in application.properties and ensure in-flight requests complete before the process exits.

Common mistakes we've seen repeat

  • Ignoring the Spring Boot starter documentation. We waste time debugging autoconfigurations that are fully documented. Read the starters guide before writing custom configuration.
  • Treating local development like production. A query that's fast with 10 rows of test data becomes slow with a million. Chaos test early. Load test before launch.
  • Leaving debug logging on in production. It costs I/O and obscures the signal in logs. Use a dynamic log level system or turn off debug by default and enable it for specific packages during investigation.
  • Assuming the first architecture will scale. A monolith with one database works fine until it doesn't. Plan for horizontal scaling, caching, and database sharding from day one, even if you don't implement it immediately.
  • Skipping observability until there's a problem. By then you're flying blind. Wire up metrics, logs, and distributed tracing from the start. It's not overhead; it's insurance.

Key takeaways

  • Modern Java (records, sealed types, virtual threads, pattern matching) genuinely improves code quality and platform capability. Use it.
  • Spring Boot defaults are sensible starting points, not commandments. Understand what autoconfigurates, measure behavior, and override only with evidence and documentation.
  • Performance lives in the data layer. Measure queries, enforce pagination, keep transactions short, and profile under realistic load before shipping.
  • Production separates the concept of a feature from a working system. Security, observability, and deployment strategy must be designed in, not bolted on.
  • The hundred days weren't about memorizing APIs. They were about building the judgment to choose what matters, measure what works, and maintain systems that survive contact with real traffic and real operators.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles