Rethinking Object-Oriented Programming in Java Education
Code.org’s 2024 AP Computer Science A (APCSA) course explains in Unit 1, Lesson 3, that “In Java, a class is a programmer-defined blueprint from which objects are created. An object is an instance of a class.” On the very next slide, the instructors elaborate by saying “An instance of a class means that it is a copy of the class with its own unique set of information. Without the class, you can’t create an object!”
For beginning CS students, this circular explanation doesn’t clarify or motivate anything. Why do we need blueprints? What is an instance? We haven’t even finished the first week of school!
Object-oriented programming (OOP) is widely used in the software industry, but it involves concepts that are abstract and difficult to motivate without a strong foundational understanding of the language. Furthermore, forcing these concepts onto students hinders critical thinking — students don’t learn why a pattern should be employed, or that there even exists a choice.
Curriculum designers introduce these concepts early on because introductory courses have to prepare students to be able to write real programs within a short timeframe. OOP is used extensively in real-world applications to facilitate modularity, so students must be familiar with OOP design in addition to basic syntax and logic.
This expectation is reasonable: learning a programming language is like learning a foreign language. When students start learning computer science, however, they must not only learn the syntax and vocabulary of their language, but also how to express their ideas to other programmers. In order to accomplish this, students must learn structured ways to represent their logic using classes, constructors, methods, and getters and setters.
However, teaching these design patterns without justification is confusing. Students don’t understand the abstract theories about blueprints and instances (or why they should even care). Even when students memorize these templates, they don’t fully learn their purpose or limitations, and therefore can’t detach their thought process from this system. In fact, many problems don’t even need object-oriented design, and this pedagogy actually leads students to obey dogma rather than thinking critically about the nature of the problem. Fundamentally, this solution doesn’t work because students don’t even know what practical problem these object-oriented features aim to solve.
In order to really understand these tools, students need to understand their purpose in real situations. This requires starting from scratch, gaining familiarity with a subset of the language, and only introducing new features as necessary. As I will demonstrate in this blog, object-oriented design can actually be extremely intuitive and obvious to students when its use is contextualized with real practice and purpose. With this change to the curriculum, students can think critically about industrial code design while still meeting the timeline.
Inspiration
This proposal is inspired by Northeastern University Computer Science Professor Matthias Felleisen’s design recipe and curriculum, as well as Ethan McCue’s approach to teaching modern Java. At a high level, their goals for a foundational computer science curriculum are as follows:
- Students should start somewhere simple.
- Students should start writing their own programs as soon as possible.
- Students should practice by writing their own code to solve problems of increasing complexity (provided by the instructor).
- No new language features or concepts should be introduced until all the prerequisites have been covered and students understand why it is needed.
- Students should be able to practice and use any concept in isolation.
- By providing challenges that get progressively more complex, students will realize that certain tasks are either extremely tedious or straight-up impossible with their limited toolkit — only then should an instructor introduce a new concept.
In the rest of this blog, I will give some examples of how key concepts can be explained using this new style of teaching.
The Beginning
Java famously comes with tons of boilerplate for simple things like the entry point for a program. Although the syntax around public class Main { public static void main(String[] args) { ... } }
does technically carry meaning, these symbols cannot be meaningfully explained to students yet, so it’s not suitable for a day-one project. Furthermore, it tends to cause confusion with students messing up their parentheses or writing code in the wrong place (which is reasonable, because students haven’t even learned what these curly brackets are meant for anyway!).
In order to decouple beginning students from the highly front-loaded design of the Java language (with static
and class
keywords), Java’s upcoming JDK 25 introduces compact source files and instance main methods as a builtin feature, which I recommend for beginning students. Alternatively, JShell can be used if the newest install isn’t available. These serve as educational tools for students to easily experiment with code without a strict source tree and proper static scoping. This comes in handy later on as students can explore OOP bit by bit, without requiring a fully formed code structure.
The basic setup for the classic Hello World program is as follows:
System.out.println("Hello, World!");
No extra syntax is required. Students can quickly learn that Java code is structured as a sequence of semicolon-terminated statements, executed one after another:
System.out.println("Hello, World!");
System.out.println("Nice to meet you!");
This system allows students to familiarize themselves with the basics of Java, writing imperative code with variables and common datatypes. In the revised 2025-2026 APCSA curriculum, this corresponds roughly to sections 1.2–1.6. Students can explore basic console inputs, code comments, arithmetic, and control flow:
import java.util.Scanner;
// create a scanner to read system input
Scanner scanner = new Scanner(System.in);
System.out.println("What is your name?");
String name = scanner.nextLine();
// greet the user
System.out.println("Hello, " + name + "!");
System.out.println("Nice to meet you!");
System.out.println("What is your age?");
int age = scanner.nextInt();
boolean isTeen = age >= 13 && age <= 19;
if (isTeen) {
System.out.println("You are a teenager!");
} else {
System.out.println("You are not a teenager!");
}
Procedural Abstractions
One of the goals of this curriculum is to not force students to use any particular tool, but to demonstrate when it is useful for students by highlighting its pros and cons. Instructors can work their way to introducing new concepts by giving an example challenge where it might be necessary to use it (instead of what was already available to students). For example, consider this code snippet:
int myNumber = 91;
boolean myNumberPrime = true;
int mySearchNum = 2;
while (mySearchNum < myNumber) {
if (myNumber % mySearchNum == 0) {
myNumberPrime = false;
}
mySearchNum++;
}
System.out.println("My number is " + myNumber);
System.out.println(myNumber + " is prime? " + myNumberPrime);
int yourNumber = 27644437;
boolean yourNumberPrime = true;
int yourSearchNum = 2;
while (yourSearchNum < yourNumber) {
if (yourNumber % yourSearchNum == 0) {
yourNumberPrime = false;
}
yourSearchNum++;
}
System.out.println("Your number is " + yourNumber);
System.out.println(yourNumber + " is prime? " + yourNumberPrime);
Students will quickly realize that writing this type of code is highly repetitive, and requires careful management of unique variable names for each situation. What if we want to replace the while loop with a for loop? What if we want to use a more efficient algorithm for determining if a number is prime? Whenever we want to make an adjustment, we have to carefully sift through the entire code to find old usages and patch it, while also adjusting the variable names in each case. This process should clearly be generalized. Methods can be logically introduced as a named piece of code that can generalize over its input parameters to yield some sort of result:
boolean isPrime(int num) {
boolean result = true;
int searchNum = 2;
while (searchNum < num) {
if (num % searchNum == 0) {
result = false;
}
searchNum++;
}
return result;
}
int myNumber = 91;
boolean myNumberPrime = isPrime(myNumber);
System.out.println("My number is " + myNumber);
System.out.println(myNumber + " is prime? " + myNumberPrime);
int yourNumber = 27644437;
boolean yourNumberPrime = isPrime(yourNumber);
System.out.println("Your number is " + yourNumber);
System.out.println(yourNumber + " is prime? " + yourNumberPrime);
Teachers and students are encouraged to justify these choices rather than prescribing certain doctrines. For example, this refactor is useful because it defines a practical and reusable named section of code that performs a certain function. It also makes higher-level code resilient to changes in the internal implementation of the logic, such as improvements to the prime sieve algorithm (with adjusted loop bounds and short-circuiting when a factorization is found). In fancy words, methods encapsulate code and hide it behind a uniform interface (API) that can be easily accessed. Students learn to make their own design choices out of necessity, not only for their own benefit, but also to communicate their ideas to anyone they’re working with (including their teacher when asking for help).
With this approach, the introduction of procedural abstractions seems natural and motivated, and students are encouraged to think critically about code structure.
Data Abstractions
Even after repeated practice at modeling real-world problems with procedural abstractions, students will inevitably encounter limitations to the types of data that their code can handle. It’s difficult for code to model compound data when you can only pass around int
, boolean
, and String
. All we need to do is define a custom data classification:
class Cash {
int twenties;
int tens;
int ones;
}
All this does is create a custom named type that contains the listed fields. None of the complicated object-oriented relationships need to come in play yet — students can build tools around this using methods (which they already learned):
Cash makeCash(int twenties, int tens, int ones) {
Cash c = new Cash();
c.twenties = twenties;
c.tens = tens;
c.ones = ones;
return c;
}
int value(Cash c) {
return 20 * c.twenties + 10 * c.tens + 1 * c.ones;
}
Cash plus(Cash c, Cash other) {
return makeCash(
c.twenties + other.twenties,
c.tens + other.tens,
c.ones + other.ones
);
}
String description(Cash c) {
return "Cash[20*" + c.twenties
+ " + 10*" + c.tens
+ " + 1*" + c.ones
+ " = " + value(c) + "]";
}
// example usage
Cash pocket = makeCash(0, 1, 3);
Cash wallet = makeCash(3, 2, 7);
Cash total = plus(wallet, pocket);
int totalValue = value(total);
System.out.println(
"I have a total of $" + totalValue
+ " in my pocket and wallet: " + description(total)
);
Notice that, like with the procedural abstractions, none of these methods are part of any particular class, reducing the cognitive load. Students can manipulate these data objects using tools they already know. Each lesson only introduces a small concept, which can be reviewed independently, so teachers can still cover all the material within the timeline.
Students can immediately understand why this feature is useful — it would be incredibly tedious (and error-prone!) to always pass around the three constituent parameters (and also impossible to return from a method). Additionally, data modeling is easier to understand and practice than the full object-oriented system, and is a core part of the design process. By attempting to model actual business logic with and without certain features, students can develop a stronger understanding of not only the language, but the general problem-solving process.
Each individual concept can be practiced in isolation (without relying on provided template code which students don’t understand)! Just like how we can teach arithmetic and for loops by giving example applications and small challenges, we can teach individual OOP concepts using extremely small, isolated challenges covering each skill. For example, Ethan McCue provides several challenges after introducing classes, covering concepts such as field access and reference aliasing, before instance methods are even introduced. Each challenge comes with a very small code file which students can run and evaluate entirely on their own. There is no hidden code, mysterious template, or autograder. All of the example problems involve code which students could plausibly write on their own, so there is no mismatch between the lesson material and the assignments.
Putting it all Together
Again, the use of language features should always be informed by their actual necessity. Object-oriented programming isn’t our target, it’s a result that stems from the fundamental demand for powerful generalizations to handle complex, real-world logic. In particular, fully-formed classes are a result of directly binding functionality with member data. In our previous example, several different methods took in the same Cash c
as its first parameter. Arguably, those methods should simply belong to that first parameter. In Java, there is a simple transformation that can turn individual functions into class methods: rename the first parameter into Cash this
, and place it inside the body of the class. A similar operation can be done to generate a constructor. Then, the invocation of these methods is always associated with a particular instance of the object, which is implicitly treated as the this
parameter:
class Cash {
int twenties;
int tens;
int ones;
Cash(int twenties, int tens, int ones) {
this.twenties = twenties;
this.tens = tens;
this.ones = ones;
}
int value(Cash this) {
return 20 * this.twenties + 10 * this.tens + 1 * this.ones;
}
Cash plus(Cash this, Cash other) {
return new Cash(
this.twenties + other.twenties,
this.tens + other.tens,
this.ones + other.ones
);
}
String description(Cash this) {
return "Cash[20*" + this.twenties
+ " + 10*" + this.tens
+ " + 1*" + this.ones
+ " = " + this.value() + "]";
}
}
// example usage
Cash pocket = new Cash(0, 1, 3);
Cash wallet = new Cash(3, 2, 7);
Cash total = wallet.plus(pocket);
int totalValue = total.value();
System.out.println(
"I have a total of $" + totalValue
+ " in my pocket and wallet: " + total.description()
);
Then, the parameter this
can be omitted, because it is already implied by being a method declared inside of the outer class. The result is a well-contained unit of code that declares a Cash class and several useful operations it can perform. Conventionally, this would be declared in a separate file with the class’s name (in this case, Cash.java
.), so that it can be easily located in a codebase and redistributed.
Teachers should emphasize the isomorphism between the old- and new-style functions, and that the “this” parameter doesn’t actually give us new functionality, but is just a shorthand. Once again, students can understand the purpose and motivation of this language feature.
Extra Keywords
Once students understand the core concepts of object-oriented design (data modeling, then binding with required functionality), students can start to graduate to the “real” Java environment, with classes stored in separate files. (If using JShell, files might need to be linked to the main runnable using the /open command.) Then, the keywords public
and private
can be introduced as a way to enforce encapsulation and containment (to prevent accidental logic errors). These are useful in large codebases to prevent unintentional mutation or misinterpretations of variables, where the explicitly-defined, public API of a code serves as a key reference to teams of developers. Additionally, the static
keyword can be used to avoid binding a method to a target instance this
, while keeping it part of the class definition (since named Java files require all code to be defined in the body of the class).
In the end, students will reach a full comprehension of the Java language and object-oriented design without having to make any assumptions or logical leaps.
Conclusion
If it sounds like this curriculum encourages students to write bad code, it’s because it does. Beginning CS students don’t instinctively know the difference between good and bad code, so constantly forcing them to do things the “right” way doesn’t teach them anything. Students should gain this intuition by gaining firsthand experience and living with repetitive, frustrating, or clunky code. Only then will design choices and abstractions make sense.
As educators, we should rethink the way we teach abstract concepts to students. Introductory CS courses such as APCSA ought to teach program design at a fundamental level, not only the specific language’s syntax and logic. That means accepting that students will write awkward code before they write elegant code, and resisting the urge to patch every inefficiency before it becomes a meaningful lesson.
What experienced programmers perceive as obvious or “natural” is the result of years of experience — not something that can be frontloaded through templates or rules. A well-designed curriculum must create the conditions for that experience to form, by carefully choosing which concepts to introduce while practicing writing real code all along the way. As I have demonstrated in this blog, teaching object-oriented abstractions can be made extremely intuitive, when taught in an iterative way.
TL;DR
Java is a complex programming language designed to support the advanced models and relationships present in industrial software. The platform offers built-in patterns for object classes and modern abstraction hierarchies, but beginning students aren’t necessarily ready to take it all in at once. Despite being one of the most widely-used programming paradigms in the software industry, in my observations of APCSA, OOP patterns are often where students struggle the most. By taking a step back and starting with a simplified version of the programming language, students can build a stronger foundation of program design and acquire their own intuition around code architecture decisions. Through repeated practice and exposure to new forms of abstractions, learners gain familiarity with the logical problem-solving process and learn powerful lessons that stretch far beyond the Java programming language. Coding best practices don’t need to be memorized from a textbook; they can be made intuitive and obvious through a logical progression of program complexity.