Things to Know When Writing Azure Functions in Java

Azure Functions documentation leans heavily on C# examples, and most tutorials you find online follow that same pattern. Java gets a mention as a supported language, but the actual day to day friction, the IDE quirks, the local run issues, the deployment gotchas, rarely makes it into the official docs. This is a rundown of the things that actually slow Java developers down when they start writing Azure Functions, most of it learned the hard way rather than read in a guide.

Picking an IDE and Edition

Most Java developers already have IntelliJ IDEA as their default, and it works fine for Azure Functions development. The one thing worth clearing up early is which edition you actually need. Azure Functions apps have no dependency on the Spring framework, so the Community Edition is enough for this specific work. You only need Ultimate Edition if you are relying on other UE-only features for the rest of your project.

IntelliJ Community Edition covers Azure Functions app development, Ultimate Edition is not required for this specifically.
IntelliJ Community Edition covers Azure Functions app development, Ultimate Edition is not required for this specifically.

Install the Azure Toolkit for IntelliJ plugin regardless of edition. It handles the project bootstrapping and templating for Function apps, and skipping it means setting up the Maven structure and function bindings by hand, which is not worth the time saved by avoiding one plugin install.

The Mac-Specific IntelliJ Issues

If you are on a Mac, there are two issues that only show up there, and both cost me real debugging time before I understood the actual cause. Neither is a Java problem, both come from how macOS handles environment variables differently depending on how an application was launched.

The first is that opening IntelliJ from Finder or the Dock and then running the Function app locally fails with an error saying it cannot find the Azure Functions Core Tools, even though the tools are installed correctly.

IntelliJ launched from Finder fails to detect the installed Azure Functions Core Tools.
IntelliJ launched from Finder fails to detect the installed Azure Functions Core Tools.

The fix, and it is genuinely this simple once you know it, is to launch IntelliJ from the terminal instead, using the idea command. A Finder-launched application on macOS inherits a different, more limited PATH than a shell-launched one, so the JVM inside IntelliJ simply cannot see where Core Tools is installed. Launching from a terminal session gives it the full shell PATH, and the same setup starts working immediately.

The same project running without issue once IntelliJ is launched from a terminal session.
The same project running without issue once IntelliJ is launched from a terminal session.

This is one of those bugs that looks like a broken Azure Functions install when it is actually a macOS process launching quirk, and it has been an open issue on the Azure Tools for IntelliJ plugin repository for a while without a permanent fix, so the terminal launch workaround is what you are stuck with for now.

The Port Lockout Problem

The second Mac-specific annoyance shows up after you stop and restart the Function app a few times during a debugging session. Eventually you get an error saying port 7071 is already in use, even though you just stopped the app.

Port 7071 reported as already in use after stopping and restarting the Function app.
Port 7071 reported as already in use after stopping and restarting the Function app.

Azure Functions actually runs as two processes, a main runtime process and a sub-process for the language worker. In a shell session or in VS Code, stopping the main process correctly tears down the child process too. IntelliJ on Mac only kills the main process and leaves the language worker sub-process holding the port open.

The workaround is to find and kill whatever is bound to that port manually.

process_id=$(sudo lsof -nP -i4TCP:7071 | grep LISTEN | awk '{print $2}')
sudo kill -9 $process_id

If you hit this often enough, wrap it in a small script that takes the port number as an argument, so you are not retyping the lsof and awk pipeline every time you restart a debug session.

#!/bin/bash
# Finds and kills whatever process is holding a given port.
# Needed only on IntelliJ + macOS, terminal and VS Code don't need this.
set -e
port_number=${1:-7071}
process_id=$(sudo lsof -nP -i4TCP:$port_number | grep LISTEN | awk '{print $2}')
if [[ -n "$process_id" ]]; then
    sudo kill -9 $process_id
    echo "Killed process $process_id holding port $port_number"
else
    echo "No process found on port $port_number"
fi

Getting the Java Version Right at Deployment

This is the one that actually causes production incidents rather than local dev friction. Azure Functions supports Java 8 and Java 11, and your pom.xml needs both the java.version and javaVersion properties set consistently, along with the maven-compiler-plugin target matching.

<properties>
    <java.version>1.8</java.version>
    <javaVersion>8</javaVersion>
</properties>
 
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>${java.version}</source>
                <target>${java.version}</target>
            </configuration>
        </plugin>
        <plugin>
            <groupId>com.microsoft.azure</groupId>
            <artifactId>azure-functions-maven-plugin</artifactId>
            <configuration>
                <runtime>
                    <os>windows</os>
                    <javaVersion>${javaVersion}</javaVersion>
                </runtime>
            </configuration>
        </plugin>
    </plugins>
</build>

If you provision the Function app through the Azure Portal, the wizard lets you pick the Java version explicitly, so this rarely goes wrong that way. The problem shows up when the app is provisioned through Bicep or an ARM template instead. If you do not explicitly declare the Java version in the infrastructure code, Azure silently defaults the runtime to Java 8, regardless of what you built and tested locally.

resource functionApp 'Microsoft.Web/sites@2021-02-01' = {
  properties: {
    siteConfig: {
      // Linux plan
      linuxFxVersion: 'Java|11'
      // Windows plan
      javaVersion: '11'
    }
  }
}

The asymmetry here is worth remembering. If the platform is set to a higher version than your build, say Java 11 declared while you compiled for Java 8, the app deploys and runs fine, the runtime just runs your older bytecode on a newer JVM. But if the platform is set to a lower version than your build, Java 8 declared while you compiled targeting Java 11, the deployment succeeds without any error and the function app simply never runs correctly. This is exactly the kind of failure that looks fine in every automated check and only shows up when someone tries to actually invoke the function in production.

The practical takeaway is to treat the Bicep or ARM javaVersion declaration as part of your build contract, not an infrastructure detail someone else owns. If your team splits app code and infrastructure code across different owners, this is exactly the kind of setting that falls through the cracks between the two.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading