← Back to list

MQTT with Spring Boot and AWS IoT Core: A Hands-On Project (Part 2 of 2)

Introduction

Aadarsh Pandey · 2026-07-27 10:40 · 0 claps · 9.7 min read
#mqtt-broker #mqtt-protocol #aws-iot #spring-boot #iot-platform
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 📟 · Gadgets & IoT

MQTT with Spring Boot and AWS IoT Core: A Hands-On Project (Part 2 of 2)

Photo by Amal S on Unsplash

Photo by Amal S on Unsplash

Introduction

This is Part 2 of a two-part series on MQTT. If you have not read it yet, please check out Part 1 first, where the core protocol concepts are covered: publish/subscribe, brokers, topics, wildcards, and QoS levels. Everything in this article builds directly on that foundation, and terms like “QoS 1” or “wildcard subscription” are used here without re-explaining them from scratch.

Here, the goal is to move from theory to a working system. We will build a minimal Spring Boot application that publishes sensor-style telemetry to AWS IoT Core over MQTT, and demonstrate the full round trip: an HTTP request comes in, gets translated into an MQTT message, travels to AWS IoT Core, and is visible in a live subscriber within milliseconds.

This project intentionally strips out everything that is not directly about MQTT. There is no database, no S3 archiving, and no subscription code running inside the application itself. Those pieces matter in a production system, and we will explain exactly why they were left out, but they would only add noise to a project meant for learning.

Why AWS IoT Core as the Broker

In Part 1, we said the broker is the center of gravity in any MQTT system. For this project, that broker is AWS IoT Core, a fully managed MQTT broker service. Rather than installing and running broker software like **Mosquitto or [EMQX](https://www.emqx.com/en) yourself, AWS IoT Core handles routing for you and also comes with a built-in browser-based subscriber called the MQTT Test Client**, which we use here instead of writing our own subscriber code.

This is a deliberate simplification. A more complete production version of this idea would have the same application both publish and subscribe, then persist incoming messages to DynamoDB and archive raw payloads to S3. That version exists, but it teaches AWS service integration more than it teaches MQTT. This article keeps the lens narrow and sticks to the protocol.

Overall Architecture

The system has one simple, linear flow:

A caller sends a JSON payload over HTTP. The application builds an MQTT topic from the URL, serializes the payload, and publishes it to AWS IoT Core. AWS IoT Core routes the message to anything subscribed to a matching topic, in this case the browser-based Test Client. No custom code subscribes to anything.

Project Setup

Prerequisites

Before writing any code, you need a few things ready:

  • Java 17 installed, since the project targets that version.
  • Maven for building and running the application.
  • An AWS account with access to AWS IoT Core.
  • An IAM user or role with an access key and secret key.
  • curl or Postman to send test requests.

Required AWS Resources

AWS IoT Core endpoint.

This is account-specific and looks like

***<prefix>**-ats.iot.**<region>**.amazonaws.com*

You will find it in the AWS IoT console under Settings. This becomes the **AWS_IOT_ENDPOINT** environment variable.

IAM credentials with an IoT policy.

The access key and secret key used by the application need permission to call iot:Connect and iot:Publish. A minimal IAM policy for this demo looks like this:

{
    "Version": "2012–10–17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iot:Connect",
                "iot:Publish"
            ],
            "Resource": "*"
        }
    ]
}

Using **"Resource": "*"** is fine for learning purposes. In a real deployment, you would restrict this to specific client ID and topic ARNs.

AWS IoT MQTT Test Client.

This is a browser-based MQTT subscriber built directly into the AWS IoT console. There is nothing to install. Open AWS IoT Core, go to the MQTT Test Client, and subscribe to DemoTopic/# before you run the application. This is what plays the role of the subscriber for this entire demo.

Note that no AWS IoT “Thing” and no device certificate are required here. The connection authenticates through IAM using SigV4 signing over WebSocket, not through the X.509 certificate flow that physical IoT devices typically use.

Folder Structure

Six Java files and one YAML file make up the entire project. Nothing here exists purely for architectural decoration.

Code Walkthrough

pom.xml

Three dependencies matter here: spring-boot-starter-web for handling HTTP, aws-iot-device-sdk for connecting to AWS IoT Core and publishing MQTT messages, and lombok to remove constructor boilerplate.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.5</version>
        <relativePath/>
    </parent>
    <groupId>com.demo</groupId>
    <artifactId>mqtt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>software.amazon.awssdk.iotdevicesdk</groupId>
            <artifactId>aws-iot-device-sdk</artifactId>
            <version>1.33.0</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

application.yaml

Every environment-specific value comes from an environment variable, never hardcoded. This is where the AWS endpoint, credentials, and topic namespace are wired in.

spring:
  application:
    name: mqtt-demo

demo:
  aws:
    endpoint: ${AWS_IOT_ENDPOINT}
    region: ${AWS_REGION:ap-south-1}
    access-key: ${AWS_ACCESS_KEY}
    secret-access-key: ${AWS_SECRET_ACCESS_KEY}
    client-id: ${AWS_CLIENT_ID:mqtt-demo-client}
    base-topic: ${AWS_BASE_TOPIC:DemoTopic}

The ***${VAR:default}** syntax means: use the environment variable if it is set, otherwise fall back to the default after the colon. Notice that `endpoint`* and the two credential fields have no default. They are account-specific, and there is no safe default value to fall back to, so the application will fail fast if they are missing.

MqttDemoApplication.java

The entry point. @ConfigurationPropertiesScan tells Spring to find and bind DemoPropertiesautomatically, without needing an explicit bean declaration for it.

package com.demo.mqttdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class MqttDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(MqttDemoApplication.class, args);
    }
}

config/DemoProperties.java

A typed, immutable record mapping the demo.aws.* section of application.yaml into Java. Using a record here means the values are final once populated, which rules out a whole class of bugs where a field is read before it is actually set.

package com.demo.mqttdemo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "demo.aws")
public record DemoProperties(
        String endpoint,
        String region,
        String accessKey,
        String secretAccessKey,
        String clientId,
        String baseTopic
) {}

config/MqttConnectionConfig.java

This is the single most important file in the project. It builds the MqttClientConnection bean that everything else depends on.

package com.demo.mqttdemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.crt.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.crt.mqtt.MqttClientConnection;
import software.amazon.awssdk.iot.AwsIotMqttConnectionBuilder;

import java.nio.charset.StandardCharsets;

@Configuration
public class MqttConnectionConfig {

    @Bean
    public MqttClientConnection mqttClientConnection(DemoProperties props) throws Exception {
        StaticCredentialsProvider credentials =
            new StaticCredentialsProvider.StaticCredentialsProviderBuilder()
                .withAccessKeyId(props.accessKey().getBytes(StandardCharsets.UTF_8))
                .withSecretAccessKey(props.secretAccessKey().getBytes(StandardCharsets.UTF_8))
                .build();

        try (AwsIotMqttConnectionBuilder builder =
                 AwsIotMqttConnectionBuilder.newDefaultBuilder()) {

            return builder
                .withEndpoint(props.endpoint())
                .withClientId(props.clientId())
                .withWebsockets(true)
                .withWebsocketSigningRegion(props.region())
                .withWebsocketCredentialsProvider(credentials)
                .withCleanSession(true)
                .withPort(443)
                .build();
        }
    }
}

A few details worth calling out directly:

  • The builder is AutoCloseable and gets closed once .build() returns. That does not close the resulting connection, only the builder object used to construct it.
  • withWebsockets(true) and withPorts(443) together mean MQTT rides inside a WebSocket upgrade on the HTTPS port, instead of using the traditional MQTT ports 1883 or 8883. This matters because port 443 is almost never blocked by corporate firewalls, while 1883 and 8883 often are.
  • withWebsocketCredentialsProvider(credentials) is what triggers SigV4 signing on the WebSocket handshake, so AWS IoT Core can validate the connection against an IAM policy instead of a certificate.

The credential type here,

software.amazon.awssdk.crt.auth.credentials.StaticCredentialsProvider

comes from the AWS Common Runtime (CRT) library that the IoT Device SDK is built on. This is a different type from the credentials class used by the regular AWS SDK for Java v2. They hold the same values but are not interchangeable, which is a common point of confusion if you have used the standard AWS SDK before.

demo/DemoReading.java

A minimal record representing the payload the caller sends.

package com.demo.mqttdemo.demo;

public record DemoReading(
        String sensorId,
        double temperature,
        double humidity
) {}

This exists just to give the demo a realistic, structured payload instead of publishing an empty message. MQTT itself does not care about the shape of this data. Any serializable object works fine.

demo/DemoPublisher.java

Converts a DemoReading into an actual MQTT PUBLISH call.

package com.demo.mqttdemo.demo;

import com.demo.mqttdemo.config.DemoProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.crt.mqtt.MqttClientConnection;
import software.amazon.awssdk.crt.mqtt.MqttMessage;
import software.amazon.awssdk.crt.mqtt.QualityOfService;

@Slf4j
@RequiredArgsConstructor
@Component
public class DemoPublisher {

    private final MqttClientConnection connection;
    private final DemoProperties props;
    private final ObjectMapper objectMapper;

    public String publish(String room, DemoReading reading) throws Exception {
        String topic = props.baseTopic() + "/" + room;
        byte[] payload = objectMapper.writeValueAsBytes(reading);

        MqttMessage message = new MqttMessage(
            topic,
            payload,
            QualityOfService.AT_LEAST_ONCE,
            false
        );

        connection.publish(message).get();
        log.info("Published to '{}'", topic);
        return topic;
    }
}

Three things worth understanding about this code:

connection.publish(message) returns a CompletableFuture<Integer>. Calling .get() on it blocks the current thread until the broker sends back a PUBACK, confirming receipt at QoS 1. Because of this, the HTTP response to the caller is not sent until AWS IoT Core has actually acknowledged the message.

**QualityOfService.AT_LEAST_ONCE** is hardcoded here for simplicity. A more flexible version could let the caller pick QoS through the request body, but that adds request validation logic that would distract from the core point of this demo.

The topic is built as {baseTopic}/{room}, joining the configured namespace with whatever room segment came from the URL. This is a direct, practical example of the topic hierarchy discussed in Part 1.

demo/DemoController.java

The HTTP entry point, exposing one endpoint.

package com.demo.mqttdemo.demo;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RequiredArgsConstructor
@RestController
@RequestMapping("/demo/publish")
public class DemoController {

    private final DemoPublisher publisher;

    @PostMapping("/{room}")
    public ResponseEntity<Map<String, String>> publish(
            @PathVariable String room,
            @RequestBody DemoReading reading) throws Exception {
        String topic = publisher.publish(room, reading);
        return ResponseEntity.ok(Map.of("topic", topic, "status", "published"));
    }
}

The {room} path variable maps directly onto the dynamic part of the topic. A request to /demo/publish/kitchen publishes to DemoTopic/kitchen. The controller itself does nothing with the connection directly. It was opened once at startup through the Spring bean lifecycle, and every request simply reuses it.

Running the Demo: Full Simulation

This section walks through the entire flow end to end, exactly as you would run it yourself.

Step 1: Prepare the subscriber

Before starting the application, log in to the AWS console and open AWS IoT Core. Navigate to the MQTT Test Client and subscribe to DemoTopic/#. This browser tab is now your subscriber, listening for anything published under DemoTopic.

Step 2: Set environment variables and start the app

export AWS_IOT_ENDPOINT=your-prefix-ats.iot.ap-south-1.amazonaws.com
export AWS_REGION=ap-south-1
export AWS_ACCESS_KEY=your-access-key
export AWS_SECRET_ACCESS_KEY=your-secret-key
export AWS_CLIENT_ID=mqtt-demo-client
export AWS_BASE_TOPIC=DemoTopic

mvn spring-boot:run

When Spring Boot starts, the MqttConnectionConfig bean is constructed. The AwsIotMqttConnectionBuilder assembles the WebSocket connection parameters and the connection to AWS IoT Core is established, with the handshake signed using SigV4. Once that succeeds, the embedded HTTP server starts listening on port 8080.

Step 3: Send a telemetry reading

curl -X POST http://localhost:8080/demo/publish/kitchen \
     -H "Content-Type: application/json" \
     -d '{"sensorId":"sensor-01","temperature":22.5,"humidity":60.1}'

Here is exactly what happens after you hit enter, in order:

  1. DemoController receives the POST request and extracts kitchenfrom the path.

  2. publisher.publish(“kitchen”, reading) is called with the deserialized DemoReading.

  3. The reading is serialized to JSON:

{
    "sensorId": "sensor-01",
    "temperature": 22.5,
    "humidity": 60.1
}
  1. An MqttMessage is built with topic DemoTopic/kitchen, QoS **AT_LEAST_ONCE**, and retain set to false.

  2. connection.publish(message).get() sends the PUBLISH packet over the already-open WebSocket connection and blocks until a PUBACK arrives.

  3. AWS IoT Core receives the PUBLISH packet and routes it to every subscriber whose topic filter matches DemoTopic/kitchen. That includes the MQTT Test Client subscribed to DemoTopic/# .

  4. The MQTT Test Client tab in your browser updates, showing the topic DemoTopic/kitchen and the payload you sent.

  5. The PUBACK arrives back at the application, .get() returns, and the HTTP response

{
    "topic": "DemoTopic/kitchen",
    "status": "published"
}

is sent back to curl.

Step 4: Try multiple rooms

curl -X POST http://localhost:8080/demo/publish/bedroom \
     -H "Content-Type: application/json" \
     -d '{"sensorId":"sensor-02","temperature":19.8,"humidity":55.4}'

curl -X POST http://localhost:8080/demo/publish/office \
     -H "Content-Type: application/json" \
     -d '{"sensorId":"sensor-03","temperature":24.1,"humidity":48.9}'

All three messages, kitchen, bedroom, and office, arrive in the same MQTT Test Client window, even though only one subscription (DemoTopic/#) was ever registered. This is the wildcard behavior from Part 1, made visible in a live system rather than described in the abstract.

Why Certain Pieces Were Left Out

A fuller version of this project, closer to a production system, would also persist every incoming message to DynamoDB and archive the raw JSON to S3, and it would have the same application subscribe to its own topic to demonstrate the full round trip in code rather than in the AWS console. Here is why each of those was deliberately excluded from this demo:

Gaps and Honest Limitations

A few things are intentionally left unaddressed in this project, and it is worth being direct about them rather than implying this is a complete production blueprint.

This demo has no retry or reconnection handling beyond what the SDK does automatically, and it does not demonstrate what happens to in-flight subscriptions if the connection drops, since withCleanSession(true) means the broker forgets all subscription state on disconnect. It also skips authentication via X.509 certificates entirely, even though that is the more common approach for physical IoT hardware that cannot securely hold IAM access keys.

Finally, there are no automated tests included here, since the focus is on running and observing the flow manually rather than building a maintainable production codebase.

If you want to extend this project further, the natural next steps are adding the DynamoDB and S3 persistence layer described above, writing an in-application subscriber instead of relying on the MQTT Test Client, and experimenting with QoS 0 and QoS 2 to see how delivery behavior changes under each.

Closing Thoughts

Between Part 1 and Part 2, you now have both the conceptual model of MQTT and a working, runnable implementation of it on AWS IoT Core.

The protocol theory explains why the code is shaped the way it is: the persistent connection, the topic-based addressing, the QoS-driven blocking .get() call, none of these are arbitrary choices, they are direct expressions of what MQTT was designed to do.

If you build on this project, the best next step is running the exact simulation above yourself and watching the message arrive in the MQTT Test Client in real time. Seeing it happen once will do more for your understanding than reading about it twice. I have tried myself and you can check it out here.


메타데이터
post_id
6c3aceebcae6
slug
mqtt-with-spring-boot-and-aws-iot-core-a-hands-on-project-part-2-of-2-6c3aceebcae6
url
https://medium.com/@beingadish/mqtt-with-spring-boot-and-aws-iot-core-a-hands-on-project-part-2-of-2-6c3aceebcae6
canonical_url
https://medium.com/@beingadish/mqtt-with-spring-boot-and-aws-iot-core-a-hands-on-project-part-2-of-2-6c3aceebcae6
author_url
https://medium.com/@beingadish
status
ok
fetched_at
2026-07-29 05:58:23