Java
Software Mile builds enterprise back ends in Java – the language behind a huge share of the world’s business-critical systems, chosen when reliability, performance, and a deep talent pool matter.
Where Java Earns Its Place
- Enterprise back-end services and APIs with Spring and the mature Java ecosystem
- High-throughput and long-running systems where the JVM’s strengths show
- Integration with existing Java estates without a rewrite
- Maintainable, tested code that outlives the team that wrote it
Java is the safe, proven choice for systems that must not fall over. Tell us about your back end and we will build it to last.
Java Still Earns New Systems, Under Conditions Worth Naming
Java earns the choice when a system has to outlast the team that wrote it, when you already run a Java estate it has to live alongside, when the hiring pool matters more than the language’s ergonomics, or when sustained throughput is the defining constraint. That covers a lot of transactional software.
It is heavier than it needs to be for a small internal tool with two developers and a short life, or for a workload that is mostly data manipulation, where Python will get you there sooner. Choosing Java because it feels safe, on a project where none of those conditions apply, buys ceremony and calls it reliability.
Which Version to Target, and What Staying Behind Costs
Target a long term support release and let your dependencies decide which one; 17, 21 and 25 are the recent ones. Plenty of systems still run on Java 8, and they work, which is why nobody has touched them. The cost accrues quietly: extended support arrangements, libraries that no longer publish compatible releases, runtime improvements you cannot use, and a harder time staffing the work.
Two jumps need real planning. The module system introduced in Java 9, JPMS, surfaces every reflective access and split package in your dependency tree. The javax to jakarta namespace change touches imports across the codebase and needs compatible versions of everything that depends on those APIs. Tooling automates a good deal of both; testing what it touched is where the schedule goes.
Spring Boot, or Something Lighter?
Spring Boot is the default for good reasons: the ecosystem covers nearly anything you need, documentation and community answers are extensive, and it is common enough that new hires often arrive already knowing it. The trade is startup time and memory footprint, which matter when you are packing many services onto limited infrastructure.
Lighter frameworks start faster and use less memory, which is a real advantage in dense container environments, and they come with smaller ecosystems. Unless startup and memory are constraints you can state in numbers, what your team already knows is worth more.
What the JVM Trades for Its Throughput
The JVM performs best once it has run long enough to optimize the code it is executing and has memory to work with. That suits a service that stays up and punishes a function that starts, handles one request and exits. Container sizing deserves attention too: a heap limit and a container memory limit set independently is a common reason processes get killed under load.
If your target is a scale to zero model, either accept the cold start behavior, use ahead of time compilation, or pick a runtime designed for it. That is an architecture decision, and it belongs in the conversation before the first line of code.
Modernizing an Estate Without Stopping the Business
Rewriting a working system in one pass carries the worst risk profile of the options available, because nothing ships until everything ships. A lower risk sequence usually runs: make the build reproducible, put a test harness around the behavior you cannot afford to change, move from an application server to an embedded container, uplift the Java version, then extract capabilities one at a time behind a stable interface.
Most of those steps can be released on their own, so the work can pause when priorities change without leaving you halfway through a rewrite. The ones that cannot, a runtime uplift among them, are at least small enough to plan as a single release with a way back.
Dependency Resolution and Version Skew
Maven and Gradle disagree about how to resolve a version conflict, and the disagreement is silent. Maven takes the nearest declaration to the root of the tree, breaking ties by declaration order, so a transitive dependency two levels down loses to one a level up regardless of which is newer. Gradle takes the highest version present anywhere in the graph. The same set of libraries, moved from one build tool to the other, can therefore produce a different runtime classpath without anyone editing a version number.
Version skew rarely fails at compile time. The code compiles against one version and runs against another, so what you get is a NoSuchMethodError, NoClassDefFoundError or AbstractMethodError on a path nobody exercised in the build. Whichever tool you use, the next command is the same kind of question: mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind, or gradle dependencyInsight --dependency jackson-databind --configuration runtimeClasspath, to find out who pulled the offending version in.
- Import a BOM instead of pinning versions one at a time. Maven does this with a dependencyManagement entry of type pom and scope import, Gradle with platform(...). It pins a whole family at once, so the members stay consistent with each other rather than drifting apart one upgrade at a time.
- Turn skew into a build failure rather than a runtime one. The Maven Enforcer plugin's requireUpperBoundDeps rule fails the build when the resolved version is older than one something else asked for, and Gradle's resolutionStrategy.failOnVersionConflict() does the equivalent.
- Shaded jars do not appear in the tree. A dependency that relocates its own transitive classes into an uber jar hides them from dependency:tree entirely, so a duplicate class conflict there has to be found by unzipping the artifact or by the banDuplicateClasses rule from the extra Enforcer rule set.
- Generate an SBOM at build time. The CycloneDX plugin for Maven or Gradle emits the resolved graph, which makes the question of whether the affected version is present anywhere in the build answerable in minutes rather than by reading pom files.
Virtual Threads and What Still Blocks a Carrier Thread
Virtual threads went final in Java 21 under JEP 444. The practical effect is that blocking on I/O inside a virtual thread unmounts it from its carrier instead of parking a platform thread, so thread count stops being the ceiling on concurrent requests. What that usually does is move the ceiling somewhere else: to the connection pool, to the database, to a downstream service that was never sized for the traffic your front end can now generate. A blocking JDBC call on a virtual thread still holds its pool connection for the whole duration, and a pool of twenty still serves twenty concurrent statements. Virtual threads are also not a resource to pool. One per task is the intended usage; an executor that hands out a fixed set of them reintroduces exactly the limit they remove.
Two things still pin a carrier thread. On Java 21 through 23, a virtual thread that blocks inside a synchronized block or method pins its carrier, and the workaround on those releases is a ReentrantLock. JEP 491 removed that pinning in Java 24 and the change is carried into Java 25, so the caveat applies to 21 through 23 only. Native frames still pin on every release, which makes a JNI call under load worth checking. The diagnostic on 21 through 23 is -Djdk.tracePinnedThreads=full, which prints the stack at the pin; from 24 onward the jdk.VirtualThreadPinned Flight Recorder event covers what remains.
Two adjacent features are worth keeping separate from virtual threads themselves. Thread locals still work, but code that stores a kilobyte per thread behaves very differently when there are a million threads instead of two hundred, and ScopedValue is the intended replacement for that pattern. Structured concurrency is a different thing again: through Java 25 it remained a preview API, usable only with --enable-preview, so check the release notes for the JDK you are actually targeting before putting load bearing code on it.
What the ORM Hides, and How to See It
An ORM hides the query, which is convenient until the query is the problem. With JPA and Hibernate, associations load lazily by default, so touching one after the persistence context has closed throws LazyInitializationException. Both reflex fixes cost you something: marking the association EAGER pulls it on every read path including the ones that never look at it, and leaving Spring Boot's spring.jpa.open-in-view enabled keeps the session open through view rendering, so the exception disappears and the queries do not. The N plus one query is a frequent performance defect in a JPA codebase, and it does not show up in a unit test with three rows in the table.
The measurement is a statement count, not a stopwatch. Set the org.hibernate.SQL logger to DEBUG, or turn on hibernate.generate_statistics, and count how many statements one request issues. A request that issues two hundred is a different problem from a request that issues one slow statement, and the fixes do not overlap. For the first, a join fetch or an @EntityGraph on that specific read path, or @BatchSize so a collection loads in chunks rather than one row at a time. For the second, read the execution plan. Where a read path needs three columns of a ten column entity, a projection query returning a DTO avoids loading and dirty checking the rest.
- Schema changes belong to Flyway or Liquibase, not to hbm2ddl.auto. Set spring.jpa.hibernate.ddl-auto to validate in every environment above a developer laptop, so a mismatch between entity and schema fails at startup instead of altering a live table.
- Pool size is arithmetic, not a preference. HikariCP defaults to ten connections; that number multiplied by the replica count has to stay under the database's own connection limit, and that limit is shared with every other thing pointed at the same instance.
- Concurrent writes need a decision rather than a default. A @Version column gives optimistic locking and an exception the caller has to handle. Pessimistic locking gives a row lock and a queue. Doing neither means last write wins, which is a choice whether or not anyone made it deliberately.
- Not every read path needs entities. Spring's JdbcClient or a query builder returns rows without a persistence context at all, and for reporting and export paths that is usually both faster and less code.
Diagnosing a Java Process You Cannot Attach a Debugger To
Most production Java diagnosis uses tools already in the JDK. jcmd against the process id covers the common cases: Thread.print for a thread dump, GC.heap_info for occupancy, GC.heap_dump for something you can open in Eclipse MAT, and JFR.start with JFR.dump for a Flight Recorder recording, whose default profile is built to be light enough to leave running. The value of those outputs is that they separate the three causes that all present as the same complaint. Garbage collection pressure shows as time spent in collection and a heap that does not come down afterward. Lock contention shows as many threads BLOCKED on the same monitor in a single thread dump. Downstream latency shows as threads sitting in socket reads, and it is the one you cannot fix inside your own process.
Sizing a container has a second half that is not about the heap at all. Metaspace, the code cache, thread stacks at roughly a megabyte each, and direct byte buffers all live outside it, so a container that gets OOM killed while its heap graph looks healthy is a native memory question: start the JVM with -XX:NativeMemoryTracking=summary and read it back with jcmd VM.native_memory. Preferring -XX:MaxRAMPercentage to a fixed -Xmx also helps, because the heap then tracks the container limit rather than being stated a second time somewhere it can fall out of step. CPU limits propagate the same way: Runtime.availableProcessors reports the container's quota, and that number sizes the ForkJoinPool common pool and the GC threads, so half a CPU yields one processor and everything derived from it shrinks accordingly.
- -XX:+HeapDumpOnOutOfMemoryError with -XX:HeapDumpPath pointed at a mounted volume. Written to the container filesystem, the dump dies with the container that produced it.
- -Xlog:gc* written to a file that survives restarts. GC history is the one thing you cannot reconstruct after the fact from anything else.
- A Flight Recorder recording configured to dump on exit, so a crash leaves evidence instead of a gap where the interesting minute was.
- Liveness and readiness pointed at different endpoints. A single probe serving both will restart an instance that is merely busy, which turns a load problem into a restart loop.
What a Java Service Has to Talk To
Estimating a Java back end is mostly estimating its edges. Every system the service talks to needs someone on your side who owns it, a non production instance to develop against, and a decision about what the service does when it is unavailable. The edges that stall a project are almost always the ones owned by a third party rather than by you, because a credential request there moves at the vendor's pace and not at the project's. The list below is the usual inventory, and the entries that apply are worth naming before anyone commits to a sequence.
- HTTP and JSON, with an OpenAPI document as the published contract for front ends and third parties. springdoc-openapi generates it from the controllers, so the contract stays attached to the code rather than to a page someone has to remember to update.
- An identity provider, joined as an OIDC resource server. The JWT is validated against the provider's JWKS endpoint with issuer and audience both checked, and the key set is refreshed on a schedule so that a signing key rotation does not turn into an outage.
- A message broker such as Kafka, RabbitMQ or SQS, where either a schema registry or an explicit compatibility rule decides whether a producer change breaks consumers you do not control.
- A relational database through a pooled DataSource. The JDBC driver carries its own JDK baseline, and a current driver can require a newer Java than the one you had planned to run on.
- Metrics and traces: Micrometer for metrics, and either the OpenTelemetry Java agent attached with -javaagent or spans written by hand, with the trace id pushed into the logging MDC so a log line can be joined to the trace that produced it.
- Scheduled work, which needs a shared job store as soon as there is more than one replica. A plain @Scheduled method on three instances runs three times; Spring Batch or Quartz with a database backed store runs it once.
Frequently Asked Questions
Is a Java project necessarily written in Java?
No. The language and the JVM are separate things, and Kotlin, Scala, Groovy and Clojure all compile to the same bytecode, call the same libraries, and ship as the same kind of artifact. Kotlin in particular interoperates with Spring and with existing Java classes closely enough that one codebase can hold both, file by file. The real constraint is not technical: whoever maintains the result afterward has to be able to read both.
What does a Java service actually run on?
A JVM, most often inside a Linux container image, on whatever runs containers: Kubernetes, ECS, a plain Docker host, or a virtual machine with systemd. The image needs a runtime rather than a full JDK, and a jlink built runtime containing only the modules the application uses is smaller than a stock JRE image. If the estate requires deployment into an existing application server instead, that constrains both the framework and the packaging, since you are producing a WAR rather than an executable JAR. That is worth establishing before design rather than discovering during deployment.
What has to exist on your side before back end work can start?
A repository we can commit to, or the authority to create one. A build that runs on a clean machine, or agreement that getting it there is the first piece of work. Credentials for non production instances of every system the service integrates with, because a service that cannot reach its dependencies can only be tested against fakes. And a named person who can answer domain questions, since most of what blocks work is a question about the business rule rather than about the code. Decide early who holds production secrets, because that determines what can be tested end to end and what cannot.
How does the code get from a developer machine to production?
The usual shape is: build and test on every commit, produce a container image tagged with the commit, run database migrations as a separate step that can be inspected and reversed on its own, then deploy the image with configuration supplied by the environment rather than baked into the artifact. Migrations are the part that needs care, because a schema change and a code change deployed together only work in one order. Expanding first and contracting later, meaning add the column, deploy code that writes both, backfill, then remove the old one, keeps each step independently reversible. Readiness and health endpoints are what the platform uses to decide whether the rollout worked.
How do you handle a dependency with a published vulnerability?
The first question is whether it is reachable. A scanner reports the presence of a version, not use of the affected class, so a serious sounding advisory in a library you call one method of may not affect you at all, while a low scoring one sitting on a path exposed to unauthenticated input can matter more than its score suggests. The CVSS number describes the vulnerability, not your exposure to it. Once reachability is settled the options are a version bump, cheap when the fix landed in a patch release and much less so when it landed in a major version that changed its API, a transitive override pinning the safe version, or a control at the boundary while you wait for an upstream release.
Do we need a Java developer of our own to maintain it?
Eventually yes, or a standing arrangement with someone who is. What decides how painful that is comes down to what the handover contains: a build that works from a clean checkout, a runbook for the failure modes that have actually occurred, the reasoning behind decisions that look arbitrary from the outside, and dependency updates treated as routine maintenance. A codebase that has gone two years without an update is hard to hand to anyone, because the first small change now requires a large version jump before it can even be tested.
Which decisions are expensive to reverse later, and which are not?
The expensive ones are about data and contracts. Whether the service owns its schema or shares a database with something else determines whether it can ever be changed independently. The shape of the public API and how it will be versioned is expensive because other people build against it. The identity model, meaning who issues tokens and what a token asserts, is expensive because every caller has to change at the same time. And the mapping from domain to tables is expensive once production data exists in the shape it chose. Most of the rest is cheaper than it feels at the time: the CI provider, the metrics backend, the logging library and the exact set of dependencies can all be swapped later with contained effort.