Adding the Application Interface
At this point you have a domain model, a data layer, and business logic assembled in service objects. What is missing is the entry point — the layer that accepts user input, invokes the right service, and presents the result. In this lesson you will build a robust Command-Line Interface (CLI) first, then understand how to replace or complement it with an HTTP interface, and learn the design principles that keep your interface code thin and easily swappable.
Why the Interface Must Be a Thin Layer
The single responsibility of the interface layer is translation: raw user input → method calls on services, and service results → human-readable (or machine-readable) output. Business rules must not leak into this layer. If you catch yourself writing validation logic or conditional branching based on domain state inside your CLI or controller, that code belongs in a service or domain object instead.
The Dependency Rule: The interface layer depends on the service layer; the service layer never depends on the interface layer. This keeps CLI and HTTP interchangeable — you can add an HTTP server later without touching any business logic.
Designing a Structured CLI Entry Point
Java does not ship a batteries-included CLI framework in the standard library, but the pattern of parsing args[] into a command/subcommand structure is straightforward to build yourself. For production projects, the picocli library is the professional standard, but here you will implement the pattern from scratch so the mechanics are transparent.
Start with a Main class that wires dependencies and dispatches commands:
public class Main {
public static void main(String[] args) {
// 1. Bootstrap infrastructure
DataSource dataSource = DataSourceFactory.create();
TaskRepository taskRepo = new JdbcTaskRepository(dataSource);
UserRepository userRepo = new JdbcUserRepository(dataSource);
// 2. Build services
TaskService taskService = new TaskService(taskRepo);
UserService userService = new UserService(userRepo);
// 3. Build CLI and run
Cli cli = new Cli(taskService, userService);
cli.run(args);
}
}
Notice that Main is the only class that knows about every concrete type — this is the Composition Root pattern. All other classes depend on interfaces, making them independently testable.
Implementing the CLI Dispatcher
The Cli class reads the first argument as a command name and dispatches to a handler:
public class Cli {
private final TaskService taskService;
private final UserService userService;
private final PrintWriter out;
public Cli(TaskService taskService, UserService userService) {
this(taskService, userService, new PrintWriter(System.out, true));
}
// Constructor for testability — inject any Writer
public Cli(TaskService taskService, UserService userService, PrintWriter out) {
this.taskService = taskService;
this.userService = userService;
this.out = out;
}
public void run(String[] args) {
if (args.length == 0) {
printHelp();
return;
}
String command = args[0].toLowerCase();
String[] rest = Arrays.copyOfRange(args, 1, args.length);
try {
switch (command) {
case "task:add" -> handleTaskAdd(rest);
case "task:list" -> handleTaskList(rest);
case "task:done" -> handleTaskDone(rest);
case "task:delete" -> handleTaskDelete(rest);
case "user:create" -> handleUserCreate(rest);
case "help" -> printHelp();
default -> {
out.println("Unknown command: " + command);
printHelp();
}
}
} catch (IllegalArgumentException e) {
// Validation failures from the service layer — user-facing message
out.println("Error: " + e.getMessage());
} catch (RuntimeException e) {
// Unexpected infrastructure failures
out.println("Unexpected error: " + e.getMessage());
}
}
private void handleTaskAdd(String[] args) {
requireArgs(args, 2, "task:add <userId> <title>");
long userId = parseLong(args[0], "userId");
String title = args[1];
Task task = taskService.createTask(userId, title);
out.printf("Created task #%d: %s%n", task.getId(), task.getTitle());
}
private void handleTaskList(String[] args) {
requireArgs(args, 1, "task:list <userId>");
long userId = parseLong(args[0], "userId");
List<Task> tasks = taskService.getTasksForUser(userId);
if (tasks.isEmpty()) {
out.println("No tasks found.");
} else {
tasks.forEach(t -> out.printf("[%s] #%d %s%n",
t.isDone() ? "x" : " ", t.getId(), t.getTitle()));
}
}
private void handleTaskDone(String[] args) {
requireArgs(args, 1, "task:done <taskId>");
long taskId = parseLong(args[0], "taskId");
taskService.markDone(taskId);
out.println("Task #" + taskId + " marked as done.");
}
private void handleTaskDelete(String[] args) {
requireArgs(args, 1, "task:delete <taskId>");
long taskId = parseLong(args[0], "taskId");
taskService.deleteTask(taskId);
out.println("Task #" + taskId + " deleted.");
}
private void handleUserCreate(String[] args) {
requireArgs(args, 1, "user:create <email>");
User user = userService.register(args[0]);
out.printf("Created user #%d (%s)%n", user.getId(), user.getEmail());
}
private void requireArgs(String[] args, int n, String usage) {
if (args.length < n) {
throw new IllegalArgumentException("Usage: " + usage);
}
}
private long parseLong(String value, String name) {
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(name + " must be a number, got: " + value);
}
}
private void printHelp() {
out.println("""
Usage: app <command> [options]
Commands:
task:add <userId> <title> Create a task for a user
task:list <userId> List all tasks for a user
task:done <taskId> Mark a task as done
task:delete <taskId> Delete a task
user:create <email> Register a new user
help Show this message
""");
}
}
Inject the output writer. Accepting a PrintWriter in the constructor instead of hard-coding System.out lets unit tests pass a StringWriter and assert on the output without capturing stdout. This is a small but powerful design decision.
Running the Application
After packaging to a fat JAR (covered in Lesson 9), the commands look like:
# Register a user
java -jar app.jar user:create alice@example.com
# Add a task
java -jar app.jar task:add 1 "Write unit tests"
# List tasks
java -jar app.jar task:list 1
# Mark done
java -jar app.jar task:done 1
Adding an Interactive REPL Mode
For richer interaction you can wrap the dispatcher in a Scanner loop that reads lines from System.in:
public void repl() {
out.println("Task Manager — type 'help' for commands, 'exit' to quit.");
try (Scanner scanner = new Scanner(System.in)) {
while (scanner.hasNextLine()) {
String line = scanner.nextLine().trim();
if (line.equalsIgnoreCase("exit")) break;
if (line.isBlank()) continue;
run(line.split("\\s+", -1));
}
}
out.println("Goodbye.");
}
When to Add an HTTP Layer Instead
A CLI is ideal for developer tools, batch jobs, and scripts. When your application needs to serve multiple concurrent users, integrate with other services, or expose a standard API, you add an HTTP layer. The key insight is that your service layer does not change at all — you simply write a new adapter.
With the lightweight Javalin framework (a single dependency, ~900 KB), adding HTTP takes roughly 30 lines:
import io.javalin.Javalin;
import io.javalin.http.Context;
public class HttpServer {
private final TaskService taskService;
private final UserService userService;
public HttpServer(TaskService taskService, UserService userService) {
this.taskService = taskService;
this.userService = userService;
}
public void start(int port) {
Javalin app = Javalin.create().start(port);
app.get("/users/{id}/tasks", this::listTasks);
app.post("/users/{id}/tasks", this::createTask);
app.patch("/tasks/{id}/done", this::markDone);
app.delete("/tasks/{id}", this::deleteTask);
}
private void listTasks(Context ctx) {
long userId = Long.parseLong(ctx.pathParam("id"));
ctx.json(taskService.getTasksForUser(userId));
}
private void createTask(Context ctx) {
long userId = Long.parseLong(ctx.pathParam("id"));
String title = ctx.bodyAsClass(CreateTaskRequest.class).title();
ctx.status(201).json(taskService.createTask(userId, title));
}
private void markDone(Context ctx) {
taskService.markDone(Long.parseLong(ctx.pathParam("id")));
ctx.status(204);
}
private void deleteTask(Context ctx) {
taskService.deleteTask(Long.parseLong(ctx.pathParam("id")));
ctx.status(204);
}
}
Do not let the HTTP layer make domain decisions. A common mistake is writing if (task.getOwnerId() != userId) ctx.status(403) inside the handler. That ownership check is a business rule — it belongs in TaskService throwing an AccessDeniedException. The handler only translates the exception into an HTTP status code.
Choosing Between CLI and HTTP
- CLI first: fastest to build, zero networking overhead, perfect for scripts and developer tools.
- HTTP first: necessary for multi-user web apps, mobile backends, or microservices.
- Both: the clean architecture you have built makes it trivial to ship both interfaces from the same service layer —
Main can accept a --server flag to start the HTTP adapter instead of (or alongside) the REPL.
Summary
The interface layer translates between the outside world and your services. A well-designed CLI uses a command dispatcher, injects its output writer for testability, and forwards all domain errors cleanly to the user. When you need HTTP, a lightweight framework like Javalin wires up in minutes because your service layer is already isolated. In the next lesson you will add defensive error handling and input validation that protects both interfaces at once.