<< Home

Chapter 8: Packages and Modules

As Java projects grow, organization becomes essential. Classes alone are not enough to keep large systems manageable, so Java provides two complementary mechanisms for structuring code: packages and modules. Packages group related classes and interfaces into named namespaces, preventing conflicts and improving clarity. Modules, introduced in Java 9, build on this idea by defining explicit boundaries and dependencies between those packages, turning a collection of classes into a self-contained, versionable unit.

Packages are the foundation of code organization in Java. Every class belongs to a package, whether explicitly declared or implicitly placed in the unnamed default package. Packages make code easier to navigate, reuse, and distribute. They also form the basis for access control between classes, since Java’s visibility rules extend across package boundaries.

💡 Think of packages as folders within your project, and modules as sealed boxes that control which folders are visible to the outside world. Packages organize code, while modules define its boundaries.

Modules take this one step further. A module declares which packages it exports for use by other modules, and which other modules it depends on. This structure gives large applications a clear architecture, where internal implementation details remain hidden and only well-defined interfaces are exposed. The Java Platform Module System (JPMS) enforces these relationships at both compile time and runtime, improving reliability and security.

Together, packages and modules transform a flat collection of source files into a layered, maintainable ecosystem. Whether you are writing a small utility or a multi-module application, understanding how these features interact will help you design software that scales cleanly and compiles with confidence.

⚠️ Although older Java code often uses only packages, modern applications and libraries increasingly rely on modules for dependency management and encapsulation. Learning both is essential for writing forward-compatible Java.

Package structure and naming

A package in Java is a namespace that groups related classes, interfaces, and subpackages together under a common name. Packages provide organization, prevent name clashes, and form the basis for access control across different parts of a program. They mirror the folder structure on disk, creating a clear correspondence between code and file layout.

You declare a package at the top of a source file using the package keyword, followed by its fully qualified name. For example:

package com.example.project.utils;

public class MathHelper {
  public static int add(int a, int b) {
    return a + b;
  }
}

This declaration tells the compiler that MathHelper belongs to the package com.example.project.utils. The source file must therefore be stored in a matching folder structure:

com/
  example/
    project/
      utils/
        MathHelper.java

When compiled, the output .class file is placed in the same structure within the output directory. This physical organization ensures that large codebases remain predictable and navigable.

💡 Package names are hierarchical and typically follow reversed Internet domain notation (such as com.company.project), which guarantees global uniqueness across organizations.

Default and explicit packages

If you do not declare a package at the top of a source file, the class belongs to the default package. While this can be useful for very small programs or examples, it should be avoided in professional code because classes in the default package cannot be imported into other packages.

// default package (no declaration)
public class Hello { … }

Explicitly naming every package makes your code more modular and avoids future conflicts as projects grow. It also allows tools and IDEs to organize files correctly and manage dependencies more effectively.

⚠️ Once a class is compiled into a specific package, changing its package name later will break code that imports it. Choose package names carefully at the start of a project and keep them consistent.

Package naming conventions

Java’s package naming conventions follow clear, global rules that make code from different sources work together seamlessly:

package com.nixonpublishing.thisisjava.ch08.examples;

Package naming consistency allows developers to understand a project’s structure immediately and reduces the risk of class name collisions across different libraries.

Organizing packages by responsibility

Large projects often use packages to separate code by purpose or functionality. For example, a typical application might be divided as follows:

com.example.app
  ├─ model       // Data structures and domain classes
  ├─ view        // User interface components
  ├─ controller  // Application logic and flow
  ├─ util        // Helper and utility classes
  └─ test        // Unit tests and resources

This structure mirrors the logical architecture of the program, allowing teams to work on different components independently while maintaining a coherent layout. IDEs and build tools (such as Maven or Gradle) recognize these conventions automatically.

💡 Treat packages as architectural boundaries. Classes within a package should share a single, well-defined purpose. When a package starts to cover multiple unrelated concepts, consider splitting it into subpackages.

Importing classes and static imports

To use a class or interface from another package, you must either refer to it by its fully qualified name or import it into your source file. Imports make code more readable by allowing you to refer to classes directly without repeatedly writing their full package paths.

import com.example.project.utils.MathHelper;

public class Calculator {
  public static void main(String[] args) {
    int sum = MathHelper.add(5, 7);
    System.out.println(sum);
  }
}

Without the import statement, you would need to use the full package name each time:

int sum = com.example.project.utils.MathHelper.add(5, 7);

Imports do not copy code or create dependencies beyond what the compiler already needs. They are purely a convenience feature that tells the compiler where to find class definitions when translating source code.

💡 You can import as many classes as needed, and Java compilers resolve them efficiently. Imports do not increase program size or runtime overhead since they only affect how code is read and compiled.

Wildcard imports

You can use the asterisk (*) to import all public classes from a package:

import com.example.project.utils.*;

This allows access to all public members of that package without listing them individually. However, wildcard imports are resolved only at compile time—they do not import subpackages or increase load time.

While convenient, wildcard imports can sometimes obscure where a class comes from, especially when multiple packages contain classes with the same name.

⚠️ Use wildcard imports sparingly. Explicit imports make code clearer, easier to refactor, and less prone to naming conflicts between libraries.

Automatic imports

Every Java source file automatically imports two groups of classes:

This means you can use core classes without explicit imports:

String text = "Hello";
System.out.println(Math.sqrt(9));

However, anything outside java.lang or your current package must be imported explicitly.

Static imports

Introduced in Java 5, static imports let you import static members (fields or methods) from a class so that they can be used without qualifying their class name. This can make code cleaner in cases where static constants or utility methods are used frequently.

import static java.lang.Math.PI;
import static java.lang.Math.pow;

public class Circle {
  public static double area(double radius) {
    return PI * pow(radius, 2);
  }
}

Without static imports, you would need to write Math.PI and Math.pow() each time. Static imports are especially common in testing frameworks like JUnit, where they allow cleaner assertions:

import static org.junit.jupiter.api.Assertions.assertEquals;

assertEquals(10, calculate());
💡 Use static imports when they make expressions more readable and reduce repetition, but avoid overusing them. Too many static imports can hide where constants or methods originate.

Import order and conflicts

If two imported packages contain classes with the same name, you must refer to the ambiguous class by its fully qualified name. For example, both java.util and java.sql contain a class named Date:

import java.util.*;
import java.sql.*;

Date d = new Date();            // error: ambiguous
java.util.Date d1 = new java.util.Date();  // correct
java.sql.Date d2 = new java.sql.Date(0);   // correct

Java does not allow importing two classes with identical names into the same namespace, so qualified names must be used whenever such conflicts occur.

⚠️ Import statements do not determine which version of a class is loaded at runtime. They are purely compile-time references. The actual classpath and module configuration decide which class definitions the JVM loads.

Encapsulation across packages

Encapsulation is one of the key principles of object-oriented design, and in Java it extends beyond individual classes to include entire packages. Packages act as visibility boundaries, allowing you to control which parts of your code are accessible to other parts of the program. This helps keep internal details hidden while exposing only the elements intended for external use.

Access control in Java is managed through access modifiers: public, protected, private, and package-private (the default when no modifier is used). These determine how fields, methods, and classes can be seen or used from other packages.

ModifierVisible within same classSame packageSubclass (different package)Unrelated package
public✔✔✔✔
protected✔✔✔✖
(default) (no modifier)✔✔✖✖
private✔✖✖✖

By combining these modifiers with package structure, you can design APIs that are clear and safe to use, while preventing access to internal implementation classes that are not meant to be part of the public interface.

💡 Use public for types and methods that form part of your intended API, and keep supporting classes package-private whenever possible. Less exposure means fewer dependencies and a more stable design.

Package-private access

Any class, method, or field declared without an explicit access modifier has package-private visibility. This means it can be accessed freely by other classes in the same package but not by classes outside it, even if they are subclasses.

package com.example.shapes;

class CircleHelper {  // package-private
  static double circumference(double r) {
    return 2 * Math.PI * r;
  }
}

public class Circle {
  double radius;
  public double area() {
    return Math.PI * radius * radius;
  }
}

Here, CircleHelper is invisible outside com.example.shapes but fully accessible to other classes within that package. This technique keeps utility or implementation details internal to the package.

⚠️ Package-private visibility is often misunderstood as “default access.” It is not visible outside the package even through subclassing. To expose members to subclasses in other packages, use protected instead.

Protected access and subclassing

The protected modifier allows visibility within the same package and also to subclasses in other packages. This makes it useful for creating extensible class hierarchies while still keeping some encapsulation.

package com.example.animals;

public class Animal {
  protected void speak() {
    System.out.println("Generic animal sound");
  }
}
package com.example.animals.mammals;
import com.example.animals.Animal;

public class Dog extends Animal {
  public void bark() {
    speak();  // allowed: protected method in superclass
    System.out.println("The dog barks");
  }
}

Although speak() is not public, the subclass can still call it because it inherits from Animal. However, a non-subclass in another package would not be able to access it.

Public APIs and internal classes

When designing libraries or large systems, it is best practice to separate public API classes (those intended for use by other packages or developers) from internal implementation classes (those used only within the package). You can enforce this separation through access modifiers and careful package structure.

com.example.library
  ├─ api
  │   └─ Library.java
  └─ internal
      └─ LibraryHelper.java

Only the classes in com.example.library.api are public, while those in internal remain package-private. External code imports only from the api package, keeping internal details hidden and safe from misuse.

💡 Clearly separate your public interfaces from your internal implementation packages. This structure mirrors how Java itself organizes its libraries (for example, java.util vs sun.*).

Encapsulation and the module system

Before Java 9, package-level encapsulation was the strongest available boundary. With the introduction of the Java Platform Module System (JPMS), modules can now explicitly control which packages are visible to other modules, providing a more robust form of encapsulation at a higher level.

When you declare a module, you can export only the packages you wish to make accessible, while keeping the rest private to that module. This ensures that even public classes are not globally visible unless their packages are explicitly exported.

⚠️ Package-level encapsulation remains essential even in modular systems. Modules build on packages rather than replacing them, adding an additional layer of visibility control for large-scale applications.

Java modules

Modules were introduced in Java 9 as part of the Java Platform Module System (JPMS). They provide a higher-level way to organise code than packages, allowing you to define clear boundaries between components, declare dependencies explicitly, and control which packages are accessible to other parts of a program.

Each module has a special descriptor file named module-info.java, placed in the root of its source hierarchy. This file defines the module’s name, the packages it exports, and the other modules it requires.

module com.example.library {
  exports com.example.library.api;
  requires java.sql;
}

In this example, the module is named com.example.library. It makes the package com.example.library.api available to other modules and declares a dependency on the java.sql module from the Java standard library. All other packages remain hidden by default, even if they contain public classes.

💡 Modules extend Java’s traditional encapsulation model. Only the packages you explicitly export become accessible outside the module, which means internal packages stay private even if their classes are public.

Structure of a modular project

A typical modular project mirrors the hierarchy of its packages, with the module-info.java file sitting alongside the top-level package directory:

src/
  com.example.library/
    module-info.java
    com/
      example/
        library/
          api/
            Library.java
          internal/
            Helper.java

The compiler uses module-info.java to verify dependencies and ensure that code cannot access non-exported packages. This explicit structure helps prevent accidental coupling between unrelated parts of large systems.

The requires and exports directives

The requires directive declares a dependency on another module. It ensures that the dependent module is available both during compilation and at runtime.

requires java.xml;
requires com.example.utils;

The exports directive declares which packages in the current module are visible to other modules:

exports com.example.library.api;

All other packages remain inaccessible outside the module, even if they contain public classes. This enforces strong encapsulation and makes module boundaries explicit.

⚠️ Failing to export a package means no external module can access it. This can cause compilation errors if other modules try to use those classes. Always verify which packages should be exported before compiling.

Transitive dependencies

Sometimes, a module depends on another module that itself requires additional modules. To make those secondary dependencies automatically available to modules further up the chain, use the requires transitive directive.

module com.example.library {
  requires transitive com.example.utils;
  exports com.example.library.api;
}

This ensures that any module depending on com.example.library also has access to com.example.utils without having to declare it explicitly.

Opening packages for reflection

Some libraries, such as frameworks or dependency injectors, use reflection to access private members. Because modules restrict access by default, such frameworks can no longer freely reflect on all classes. To allow reflection selectively, you can use the opens directive.

opens com.example.library.internal to com.fasterxml.jackson.databind;

This grants reflective access to the specified package, but only for the named module (com.fasterxml.jackson.databind in this case). You can also open a package to all modules:

opens com.example.library.internal;
⚠️ Use opens sparingly. It should only be applied when reflection is essential, as it weakens module encapsulation and can make systems harder to maintain.

Combining exports and opens

It is common to export API packages and open internal ones for reflection. A single module-info.java file can contain both directives:

module com.example.library {
  exports com.example.library.api;
  opens com.example.library.internal to com.fasterxml.jackson.databind;
  requires java.sql;
}

This configuration exposes only the API package for normal use, while permitting controlled reflective access to internal classes during serialization or runtime inspection.

💡 The module-info.java file acts as a manifest for the entire codebase. It defines dependencies, visibility, and reflective permissions all in one place, ensuring modular projects remain explicit and predictable.

Compiling and running modular programs

Working with modules changes how Java source code is compiled, packaged, and executed. Unlike traditional classpath-based projects, modular programs use a module path, which explicitly lists the locations of compiled modules. This separation allows the compiler and runtime to verify dependencies and prevent unwanted access to non-exported packages.

Compiling a modular project

To compile a modular project, use the javac command with the --module-source-path option. This tells the compiler where to find the module source directories. Each module must have its own folder containing a module-info.java file and its source packages.

project/
  └─ src/
     ├─ com.example.library/
     │   ├─ module-info.java
     │   └─ com/example/library/api/Library.java
     └─ com.example.app/
         ├─ module-info.java
         └─ com/example/app/Main.java

To compile both modules into a separate output directory named mods:

javac -d mods --module-source-path src $(find src -name "*.java")

This command compiles every source file within the src tree, preserving the module structure inside mods. Each module will have its own directory in the output, for example:

mods/
  ├─ com.example.library/
  └─ com.example.app/
💡 Each module is compiled independently but aware of its declared dependencies via requires statements. The compiler ensures that all required modules are available on the module path.

Running modular applications

To run a modular program, use the java command with the --module-path option, specifying where compiled modules are located, followed by the --module (or -m) option to identify which module and main class to launch.

java --module-path mods -m com.example.app/com.example.app.Main

The syntax after -m consists of the module name, a slash, and the fully qualified class name containing the main() method. The JVM verifies dependencies and ensures that all required modules are present before execution begins.

⚠️ The traditional classpath is still available for non-modular code, but it cannot access internal (non-exported) packages of modules. Always use the module path when working with modular applications.

Combining modular and non-modular code

During the transition to modular development, projects may mix modular and legacy code. The Java module system supports this through the unnamed module and the automatic module features.

These mechanisms simplify migration from older codebases while gradually introducing modularity.

java --module-path mods:libs -m com.example.app/com.example.app.Main

Here, mods contains compiled named modules, and libs contains third-party automatic modules. The JVM resolves both during startup.

💡 Use automatic modules only as a transitional step. For long-term projects, define a module-info.java for each component to ensure predictable behaviour and strong encapsulation.

Packaging and distributing modules

Once compiled, a module can be packaged into a standard JAR file. To make it modular, include the compiled module-info.class file in the JAR’s root directory:

jar --create --file dist/com.example.library.jar -C mods/com.example.library .

This produces a modular JAR that other projects can reference via the module path. You can verify the module’s metadata using the jar or jdeps tools:

jar --describe-module --file dist/com.example.library.jar

To run a modular JAR directly:

java --module-path dist -m com.example.library/com.example.library.Main
⚠️ A JAR without a module-info.class file is treated as an automatic module when placed on the module path. For full compatibility, always include an explicit module descriptor.

Advantages of modular execution

By compiling and running programs as modules, you gain control over both structure and dependencies. The module system transforms how large Java applications are built, shared, and maintained, bringing consistency and clarity to every stage of development.

💡 Think of the module path as a precise map of your program. It defines not just where code lives, but also what it can see and depend upon. This explicitness is the key advantage of modular Java.